diff --git a/.github/workflows/build-all-test.yml b/.github/workflows/build-all-test.yml index d3b855e58..3c110b902 100644 --- a/.github/workflows/build-all-test.yml +++ b/.github/workflows/build-all-test.yml @@ -1,17 +1,277 @@ name: Build All (Test) -# Manually-triggered workflow that builds all SDKs and example projects. -# Use this to verify everything compiles after changes. +# ============================================================================= +# Comprehensive Build Verification Workflow +# +# Manually-triggered workflow with checkboxes for each build target. +# Select what you want to build and hit "Run workflow". +# +# Targets: +# - C++ Android Backends (arm64-v8a, armeabi-v7a, x86_64) +# - C++ iOS Backends (XCFramework) +# - Kotlin SDK (JVM + Android) +# - Swift SDK (iOS/macOS) +# - Web SDK (TypeScript) +# - Flutter SDK (Dart analyze) +# - React Native SDK (TypeScript) +# - Android Example Apps +# - IntelliJ Plugin +# ============================================================================= on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + build_cpp_android: + description: 'C++ Android Backends (arm64-v8a, armeabi-v7a, x86_64)' + required: false + default: true + type: boolean + build_cpp_ios: + description: 'C++ iOS Backends (XCFramework)' + required: false + default: true + type: boolean + build_kotlin_sdk: + description: 'Kotlin SDK (JVM + Android)' + required: false + default: true + type: boolean + build_swift_sdk: + description: 'Swift SDK (iOS/macOS)' + required: false + default: true + type: boolean + build_web_sdk: + description: 'Web SDK (TypeScript)' + required: false + default: true + type: boolean + build_flutter_sdk: + description: 'Flutter SDK (Dart)' + required: false + default: true + type: boolean + build_react_native_sdk: + description: 'React Native SDK (TypeScript)' + required: false + default: true + type: boolean + build_android_apps: + description: 'Android Example Apps' + required: false + default: true + type: boolean + build_intellij_plugin: + description: 'IntelliJ Plugin' + required: false + default: true + type: boolean permissions: contents: read +env: + COMMONS_DIR: sdk/runanywhere-commons + jobs: + # =========================================================================== + # C++ Android Backends (Matrix: arm64-v8a, armeabi-v7a, x86_64) + # =========================================================================== + cpp-android: + name: C++ Android (${{ matrix.abi }}) + if: ${{ inputs.build_cpp_android }} + 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: 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:-https://placeholder.supabase.co}" | tr -d '\n\r') + CLEAN_KEY=$(printf '%s' "${SUPABASE_ANON_KEY:-placeholder_key}" | tr -d '\n\r') + CLEAN_TOKEN=$(printf '%s' "${BUILD_TOKEN:-bt_test_build}" | tr -d '\n\r') + + cat > "$CONFIG_FILE" << CONFIGEOF + #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; + } + + 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; } + } + CONFIGEOF + + - name: Download Dependencies + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/android/download-sherpa-onnx.sh + rm -rf third_party/sherpa-onnx-android + ./scripts/android/download-sherpa-onnx.sh + + - name: Build C++ Android ${{ matrix.abi }} + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-android.sh + ./scripts/build-android.sh all ${{ matrix.abi }} + + - name: List Build Output + working-directory: ${{ env.COMMONS_DIR }} + run: | + echo "=== Build Artifacts ===" + find dist/android -name "*.so" -type f 2>/dev/null | head -30 || echo "No .so files found" + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: cpp-android-${{ matrix.abi }} + path: ${{ env.COMMONS_DIR }}/dist/android/ + retention-days: 7 + + # =========================================================================== + # C++ iOS Backends (XCFramework) + # =========================================================================== + cpp-ios: + name: C++ iOS (XCFramework) + if: ${{ inputs.build_cpp_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: 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:-https://placeholder.supabase.co}" | tr -d '\n\r') + CLEAN_KEY=$(printf '%s' "${SUPABASE_ANON_KEY:-placeholder_key}" | tr -d '\n\r') + CLEAN_TOKEN=$(printf '%s' "${BUILD_TOKEN:-bt_test_build}" | tr -d '\n\r') + + cat > "$CONFIG_FILE" << CONFIGEOF + #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; + } + + 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; } + } + CONFIGEOF + + - 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 RACommons + All Backends (iOS) + working-directory: ${{ env.COMMONS_DIR }} + run: | + chmod +x scripts/build-ios.sh + ./scripts/build-ios.sh --backend all --release --package + + - name: List Build Output + working-directory: ${{ env.COMMONS_DIR }} + run: | + echo "=== Build Artifacts ===" + ls -la dist/ + [ -d dist/RACommons.xcframework ] && echo "RACommons.xcframework" || echo "RACommons.xcframework MISSING" + [ -d dist/RABackendLLAMACPP.xcframework ] && echo "RABackendLLAMACPP.xcframework" || echo "RABackendLLAMACPP.xcframework MISSING" + [ -d dist/RABackendONNX.xcframework ] && echo "RABackendONNX.xcframework" || echo "RABackendONNX.xcframework MISSING" + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: cpp-ios-xcframeworks + path: | + ${{ env.COMMONS_DIR }}/dist/RACommons.xcframework + ${{ env.COMMONS_DIR }}/dist/RABackendLLAMACPP.xcframework + ${{ env.COMMONS_DIR }}/dist/RABackendONNX.xcframework + retention-days: 7 + + # =========================================================================== + # Kotlin SDK (JVM + Android) + # =========================================================================== kotlin-sdk: name: Kotlin SDK (JVM + Android) + if: ${{ inputs.build_kotlin_sdk }} runs-on: ubuntu-latest steps: @@ -29,8 +289,7 @@ jobs: - name: Setup local.properties working-directory: sdk/runanywhere-kotlin - run: | - echo "sdk.dir=${ANDROID_SDK_ROOT}" > local.properties + run: echo "sdk.dir=${ANDROID_SDK_ROOT}" > local.properties - name: Build JVM JAR working-directory: sdk/runanywhere-kotlin @@ -55,8 +314,31 @@ jobs: run: ./gradlew publishToMavenLocal -Prunanywhere.testLocal=false continue-on-error: true + # =========================================================================== + # Swift SDK (iOS/macOS) + # =========================================================================== + swift-sdk: + name: Swift SDK + if: ${{ inputs.build_swift_sdk }} + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Swift Build (remote XCFrameworks) + run: swift build + + - name: Swift Test + run: swift test + continue-on-error: true + + # =========================================================================== + # Web SDK (TypeScript) + # =========================================================================== web-sdk: name: Web SDK (TypeScript) + if: ${{ inputs.build_web_sdk }} runs-on: ubuntu-latest steps: @@ -80,23 +362,78 @@ jobs: working-directory: sdk/runanywhere-web/packages/core run: npx tsc - swift-sdk: - name: Swift SDK - runs-on: macos-14 + # =========================================================================== + # Flutter SDK (Dart) + # =========================================================================== + flutter-sdk: + name: Flutter SDK (Dart) + if: ${{ inputs.build_flutter_sdk }} + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Swift Build (remote XCFrameworks) - run: swift build + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.27.x' + channel: 'stable' - - name: Swift Test - run: swift test + - name: Install Melos + run: dart pub global activate melos + + - name: Bootstrap Packages + working-directory: sdk/runanywhere-flutter + run: melos bootstrap + + - name: Analyze All Packages + working-directory: sdk/runanywhere-flutter + run: melos run analyze || dart analyze packages/runanywhere packages/runanywhere_llamacpp packages/runanywhere_onnx + continue-on-error: true + + - name: Run Tests + working-directory: sdk/runanywhere-flutter + run: melos run test || true + continue-on-error: true + + # =========================================================================== + # React Native SDK (TypeScript) + # =========================================================================== + react-native-sdk: + name: React Native SDK (TypeScript) + if: ${{ inputs.build_react_native_sdk }} + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Dependencies + working-directory: sdk/runanywhere-react-native + run: yarn install --frozen-lockfile || npm install + + - name: TypeScript Typecheck + working-directory: sdk/runanywhere-react-native + run: npx tsc --noEmit -p tsconfig.base.json + continue-on-error: true + + - name: Build Packages + working-directory: sdk/runanywhere-react-native + run: npx lerna run build || true continue-on-error: true - android-app: - name: Android Example App + # =========================================================================== + # Android Example Apps + # =========================================================================== + android-apps: + name: Android Example Apps + if: ${{ inputs.build_android_apps }} runs-on: ubuntu-latest steps: @@ -115,17 +452,29 @@ jobs: - name: Setup local.properties run: | echo "sdk.dir=${ANDROID_SDK_ROOT}" > examples/android/RunAnywhereAI/local.properties + echo "sdk.dir=${ANDROID_SDK_ROOT}" > examples/android/RunAnyWhereLora/local.properties echo "sdk.dir=${ANDROID_SDK_ROOT}" > sdk/runanywhere-kotlin/local.properties - - name: Build Android App + - name: Build RunAnywhereAI working-directory: examples/android/RunAnywhereAI run: | chmod +x gradlew ./gradlew assembleDebug -Prunanywhere.testLocal=false continue-on-error: true + - name: Build RunAnyWhereLora + working-directory: examples/android/RunAnyWhereLora + run: | + chmod +x gradlew + ./gradlew assembleDebug -Prunanywhere.testLocal=false + continue-on-error: true + + # =========================================================================== + # IntelliJ Plugin + # =========================================================================== intellij-plugin: name: IntelliJ Plugin + if: ${{ inputs.build_intellij_plugin }} runs-on: ubuntu-latest steps: @@ -142,8 +491,7 @@ jobs: uses: android-actions/setup-android@v3 - name: Setup local.properties - run: | - echo "sdk.dir=${ANDROID_SDK_ROOT}" > sdk/runanywhere-kotlin/local.properties + run: echo "sdk.dir=${ANDROID_SDK_ROOT}" > sdk/runanywhere-kotlin/local.properties - name: Publish SDK to Maven Local working-directory: sdk/runanywhere-kotlin @@ -157,9 +505,21 @@ jobs: chmod +x gradlew ./gradlew buildPlugin + # =========================================================================== + # Build Summary + # =========================================================================== summary: name: Build Summary - needs: [kotlin-sdk, web-sdk, swift-sdk, android-app, intellij-plugin] + needs: + - cpp-android + - cpp-ios + - kotlin-sdk + - swift-sdk + - web-sdk + - flutter-sdk + - react-native-sdk + - android-apps + - intellij-plugin if: always() runs-on: ubuntu-latest @@ -168,13 +528,32 @@ jobs: run: | echo "## Build All (Test) Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "| Project | Status |" >> $GITHUB_STEP_SUMMARY - echo "|---------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Kotlin SDK (JVM + Android) | ${{ needs.kotlin-sdk.result == 'success' && 'Pass' || needs.kotlin-sdk.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Web SDK (TypeScript) | ${{ needs.web-sdk.result == 'success' && 'Pass' || needs.web-sdk.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Swift SDK | ${{ needs.swift-sdk.result == 'success' && 'Pass' || needs.swift-sdk.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Android Example App | ${{ needs.android-app.result == 'success' && 'Pass' || needs.android-app.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| IntelliJ Plugin | ${{ needs.intellij-plugin.result == 'success' && 'Pass' || needs.intellij-plugin.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Target | Status |" >> $GITHUB_STEP_SUMMARY + echo "|--------|--------|" >> $GITHUB_STEP_SUMMARY + + # Helper: map job result to display string + status() { + case "$1" in + success) echo "Pass" ;; + skipped) echo "Skipped (unchecked)" ;; + failure) echo "FAILED" ;; + cancelled) echo "Cancelled" ;; + *) echo "$1" ;; + esac + } + + echo "| C++ Android Backends | $(status '${{ needs.cpp-android.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| C++ iOS Backends | $(status '${{ needs.cpp-ios.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| Kotlin SDK (JVM + Android) | $(status '${{ needs.kotlin-sdk.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| Swift SDK | $(status '${{ needs.swift-sdk.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| Web SDK (TypeScript) | $(status '${{ needs.web-sdk.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| Flutter SDK (Dart) | $(status '${{ needs.flutter-sdk.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| React Native SDK | $(status '${{ needs.react-native-sdk.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| Android Example Apps | $(status '${{ needs.android-apps.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "| IntelliJ Plugin | $(status '${{ needs.intellij-plugin.result }}') |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY - echo "Triggered by: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY - echo "Commit: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "Triggered by: **${{ github.actor }}**" >> $GITHUB_STEP_SUMMARY + echo "Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "Branch: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 69b837be9..544725c32 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,10 @@ + + + + \ No newline at end of file diff --git a/docs/impl/lora_adapter_support.md b/docs/impl/lora_adapter_support.md new file mode 100644 index 000000000..5c3c0a54f --- /dev/null +++ b/docs/impl/lora_adapter_support.md @@ -0,0 +1,700 @@ +# LoRA Adapter Support - Implementation Documentation + +## Table of Contents + +- [Overview](#overview) +- [Kotlin SDK Usage Guide](#kotlin-sdk-usage-guide) + - [Prerequisites](#prerequisites) + - [Data Types](#data-types) + - [Loading a LoRA Adapter](#loading-a-lora-adapter) + - [Stacking Multiple Adapters](#stacking-multiple-adapters) + - [Removing Adapters](#removing-adapters) + - [Querying Loaded Adapters](#querying-loaded-adapters) + - [Error Handling](#error-handling) + - [Android ViewModel Example](#android-viewmodel-example) +- [C/C++ API Reference](#cc-api-reference-for-other-sdk-implementations) + - [Component API (Recommended)](#api-level-1-component-api-recommended) + - [Backend API (LlamaCPP-specific)](#api-level-2-backend-api-llamacpp-specific) + - [Vtable Integration](#vtable-integration-for-new-backends) + - [C Usage Example](#usage-example-c) + - [Swift Usage Example](#usage-example-swift----ios-sdk-pattern) + - [Return Codes Reference](#return-codes-reference) +- [Architecture](#architecture) + - [Layer Diagram](#layer-diagram) + - [Vtable Dispatch](#vtable-dispatch) +- [llama.cpp LoRA API (b8011)](#llamacpp-lora-api-b8011) +- [Optimizations and Design Decisions](#optimizations-and-design-decisions) + - [Context Recreation](#context-recreation) + - [KV Cache Invalidation](#kv-cache-invalidation) + - [Thread Safety](#thread-safety) + - [Duplicate Detection](#duplicate-detection) + - [Rollback on Failure](#rollback-on-failure) + - [Adapter Memory Lifecycle](#adapter-memory-lifecycle) +- [Files Changed](#files-changed) +- [How to Extend](#how-to-extend) +- [Build Verification](#build-verification) +- [Changelog](#changelog) + +--- + +## Overview + +LoRA (Low-Rank Adaptation) adapter support was added to the RunAnywhere SDK across +two modules: `sdk/runanywhere-commons` (C/C++) and `sdk/runanywhere-kotlin` (Kotlin +Multiplatform). This enables users to load fine-tuned LoRA adapters (GGUF format) +alongside a base model, hot-swap adapters without reloading the base model, stack +multiple adapters with individual scales, and remove adapters at runtime. + +The implementation spans 6 layers, bottom-up: C++ internal, C API, component, +JNI bridge, Kotlin bridge, and Kotlin public API. + +--- + +## Kotlin SDK Usage Guide + +### Prerequisites + +Before using LoRA adapters: + +1. The RunAnywhere SDK must be initialized +2. The LlamaCPP backend must be registered +3. A base model must be loaded via `RunAnywhere.loadLLMModel()` +4. LoRA adapter files must be in GGUF format + +```kotlin +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.loadLoraAdapter +import com.runanywhere.sdk.public.extensions.removeLoraAdapter +import com.runanywhere.sdk.public.extensions.clearLoraAdapters +import com.runanywhere.sdk.public.extensions.getLoadedLoraAdapters +import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterConfig +import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterInfo +``` + +### Data Types + +**LoRAAdapterConfig** -- Configuration passed when loading an adapter. + +```kotlin +data class LoRAAdapterConfig( + val path: String, // Path to the LoRA GGUF file (must not be blank) + val scale: Float = 1.0f, // Scale factor: 0.0 = no effect, 1.0 = full effect, >1.0 = amplified +) +``` + +**LoRAAdapterInfo** -- Read-only info returned when querying loaded adapters. + +```kotlin +data class LoRAAdapterInfo( + val path: String, // Path used when loading + val scale: Float, // Active scale factor + val applied: Boolean, // Whether the adapter is currently applied to the context +) +``` + +### Loading a LoRA Adapter + +Load a GGUF LoRA file and apply it to the current model. The SDK recreates the +llama.cpp context internally and clears the KV cache. + +```kotlin +// Load with default scale (1.0) +RunAnywhere.loadLoraAdapter(LoRAAdapterConfig(path = "/path/to/adapter.gguf")) + +// Load with custom scale (0.5 = half strength) +RunAnywhere.loadLoraAdapter( + LoRAAdapterConfig(path = "/path/to/adapter.gguf", scale = 0.5f) +) +``` + +All functions are `suspend` -- call them from a coroutine scope. + +### Stacking Multiple Adapters + +Multiple adapters can be applied simultaneously. Each adapter has its own scale. +The effects combine additively at the weight level. + +```kotlin +// Load base writing style adapter +RunAnywhere.loadLoraAdapter( + LoRAAdapterConfig(path = "/path/to/style.gguf", scale = 1.0f) +) + +// Stack a domain knowledge adapter on top +RunAnywhere.loadLoraAdapter( + LoRAAdapterConfig(path = "/path/to/domain.gguf", scale = 0.7f) +) + +// Check what's loaded +val adapters = RunAnywhere.getLoadedLoraAdapters() +// adapters.size == 2 +``` + +### Removing Adapters + +```kotlin +// Remove a specific adapter by path +RunAnywhere.removeLoraAdapter("/path/to/style.gguf") + +// Remove all adapters at once +RunAnywhere.clearLoraAdapters() +``` + +After removal, the context is recreated and KV cache is cleared. Any remaining +adapters are re-applied automatically. + +### Querying Loaded Adapters + +```kotlin +val adapters: List = RunAnywhere.getLoadedLoraAdapters() + +for (adapter in adapters) { + println("Path: ${adapter.path}") + println("Scale: ${adapter.scale}") + println("Applied: ${adapter.applied}") +} +``` + +Returns an empty list if no adapters are loaded or if no model is loaded. + +### Error Handling + +All LoRA functions throw `SDKError` on failure: + +```kotlin +try { + RunAnywhere.loadLoraAdapter(LoRAAdapterConfig(path = "/invalid/path.gguf")) +} catch (e: SDKError) { + // SDKError.notInitialized -- SDK not initialized + // SDKError.llm -- C++ operation failed (bad path, incompatible adapter, etc.) + println("LoRA error: ${e.message}") +} +``` + +Common failure causes: +- SDK not initialized (`SDKError.notInitialized`) +- No model loaded (`SDKError.llm` with "no model loaded") +- Invalid adapter file or path (`SDKError.llm`) +- Adapter already loaded with same path (`SDKError.llm` with duplicate detection) +- Adapter incompatible with base model (`SDKError.llm`) + +### Android ViewModel Example + +A typical Android integration pattern using ViewModel and Compose: + +```kotlin +class LlmViewModel : ViewModel() { + + data class UiState( + val modelLoaded: Boolean = false, + val loraAdapters: List = emptyList(), + val error: String? = null, + ) + + private val _state = MutableStateFlow(UiState()) + val state = _state.asStateFlow() + + fun loadLoraAdapter(path: String, scale: Float = 1.0f) { + viewModelScope.launch { + try { + RunAnywhere.loadLoraAdapter(LoRAAdapterConfig(path, scale)) + refreshAdapterList() + } catch (e: SDKError) { + _state.update { it.copy(error = e.message) } + } + } + } + + fun clearAdapters() { + viewModelScope.launch { + RunAnywhere.clearLoraAdapters() + refreshAdapterList() + } + } + + private suspend fun refreshAdapterList() { + val adapters = RunAnywhere.getLoadedLoraAdapters() + _state.update { it.copy(loraAdapters = adapters) } + } +} +``` + +For a full working Android app, see `examples/android/RunAnyWhereLora/`. + +--- + +## C/C++ API Reference (for other SDK implementations) + +This section documents the C functions that back the JNI layer. Any language +that can call C functions (Swift, Python, Dart, Rust, C#, etc.) can use these +directly to implement LoRA support without going through JNI/Kotlin. + +There are two API levels to choose from: + +### API Level 1: Component API (Recommended) + +Header: `include/rac/features/llm/rac_llm_component.h` +Library: `librac_commons.so` / `RACommons.xcframework` + +These are the **high-level** functions. They handle mutex locking, service +lookup, and vtable dispatch internally. Use these unless you have a reason +to call the backend directly. + +```c +#include "rac/features/llm/rac_llm_component.h" + +// handle = the rac_handle_t returned by rac_llm_component_create() + +// ---- Load a LoRA adapter ---- +// Loads a GGUF LoRA file and applies it to the current model. +// Context is recreated internally. KV cache is cleared. +// Duplicate paths are rejected. +// +// Returns: RAC_SUCCESS, RAC_ERROR_INVALID_HANDLE, RAC_ERROR_INVALID_ARGUMENT, +// RAC_ERROR_COMPONENT_NOT_READY, RAC_ERROR_NOT_SUPPORTED, +// or backend-specific error code +rac_result_t rac_llm_component_load_lora( + rac_handle_t handle, // Component handle + const char* adapter_path, // Absolute path to LoRA .gguf file + float scale // 0.0 = no effect, 1.0 = full, >1.0 = amplified +); + +// ---- Remove a specific adapter ---- +// Removes the adapter that was loaded from the given path. +// Context is recreated and KV cache is cleared. +// +// Returns: RAC_SUCCESS, RAC_ERROR_NOT_FOUND, RAC_ERROR_COMPONENT_NOT_READY +rac_result_t rac_llm_component_remove_lora( + rac_handle_t handle, + const char* adapter_path // Must match the path used in load_lora +); + +// ---- Clear all adapters ---- +// Removes every loaded adapter. Safe to call with no adapters loaded. +// +// Returns: RAC_SUCCESS +rac_result_t rac_llm_component_clear_lora( + rac_handle_t handle +); + +// ---- Query loaded adapters ---- +// Returns a JSON array string describing all loaded adapters. +// Format: [{"path":"/path/to/file.gguf","scale":1.0,"applied":true}, ...] +// Caller MUST free the returned string with free(). +// +// Returns: RAC_SUCCESS, RAC_ERROR_COMPONENT_NOT_READY +rac_result_t rac_llm_component_get_lora_info( + rac_handle_t handle, + char** out_json // Output: heap-allocated JSON string +); +``` + +**JNI mapping** (for reference -- how the Kotlin bridge calls these): + +| JNI Function | C Function | Notes | +|---|---|---| +| `racLlmComponentLoadLora(long handle, String path, float scale)` | `rac_llm_component_load_lora(handle, path, scale)` | Returns `int` (0 = success) | +| `racLlmComponentRemoveLora(long handle, String path)` | `rac_llm_component_remove_lora(handle, path)` | Returns `int` | +| `racLlmComponentClearLora(long handle)` | `rac_llm_component_clear_lora(handle)` | Returns `int` | +| `racLlmComponentGetLoraInfo(long handle)` | `rac_llm_component_get_lora_info(handle, &json)` | Returns `String?` (JSON) | + +### API Level 2: Backend API (LlamaCPP-specific) + +Header: `include/rac/backends/rac_llm_llamacpp.h` +Library: `librac_backend_llamacpp.so` / `RABackendLLAMACPP.xcframework` + +These are **low-level** functions that talk directly to the LlamaCPP backend. +Use these if you want to bypass the component layer (e.g., building a custom +pipeline without the lifecycle manager). You must handle your own locking. + +```c +#include "rac/backends/rac_llm_llamacpp.h" + +// handle = the backend impl pointer (NOT the component handle). +// Obtained from rac_llm_service_t.impl after creating a service. + +// Load and apply a LoRA adapter. Context is recreated internally. +rac_result_t rac_llm_llamacpp_load_lora( + rac_handle_t handle, + const char* adapter_path, + float scale +); + +// Remove a specific adapter by path. +rac_result_t rac_llm_llamacpp_remove_lora( + rac_handle_t handle, + const char* adapter_path +); + +// Clear all adapters. +rac_result_t rac_llm_llamacpp_clear_lora( + rac_handle_t handle +); + +// Get adapter info as JSON. Caller must free(*out_json). +rac_result_t rac_llm_llamacpp_get_lora_info( + rac_handle_t handle, + char** out_json +); +``` + +### Vtable Integration (for new backends) + +If you are adding LoRA support to a different backend (not LlamaCPP), implement +these 4 function pointers in your `rac_llm_service_ops_t` vtable: + +```c +#include "rac/features/llm/rac_llm_service.h" + +typedef struct rac_llm_service_ops { + // ... existing ops (initialize, generate, generate_stream, etc.) ... + + // LoRA ops -- set to NULL if your backend doesn't support LoRA + rac_result_t (*load_lora)(void* impl, const char* adapter_path, float scale); + rac_result_t (*remove_lora)(void* impl, const char* adapter_path); + rac_result_t (*clear_lora)(void* impl); + rac_result_t (*get_lora_info)(void* impl, char** out_json); +} rac_llm_service_ops_t; +``` + +The component layer checks for NULL before calling. If your backend sets +these to NULL, calls return `RAC_ERROR_NOT_SUPPORTED`. + +### Usage Example (C) + +Complete example of loading a model and applying a LoRA adapter using the +component API: + +```c +#include "rac/core/rac_core.h" +#include "rac/backends/rac_llm_llamacpp.h" +#include "rac/features/llm/rac_llm_component.h" + +int main() { + // 1. Initialize SDK + rac_init(NULL); + rac_backend_llamacpp_register(); + + // 2. Create and load model via component + rac_handle_t component = 0; + rac_llm_component_create(&component); + rac_llm_component_load_model(component, "/path/to/model.gguf", + "my-model", "My Model", NULL); + + // 3. Load LoRA adapter (scale = 0.8) + rac_result_t r = rac_llm_component_load_lora( + component, "/path/to/adapter.gguf", 0.8f); + if (r != RAC_SUCCESS) { + printf("Failed to load LoRA: %s\n", rac_error_message(r)); + return 1; + } + + // 4. Stack a second adapter + rac_llm_component_load_lora(component, "/path/to/adapter2.gguf", 0.5f); + + // 5. Query what's loaded + char* json = NULL; + rac_llm_component_get_lora_info(component, &json); + if (json) { + printf("Adapters: %s\n", json); + // Output: [{"path":"/path/to/adapter.gguf","scale":0.8,"applied":true}, + // {"path":"/path/to/adapter2.gguf","scale":0.5,"applied":true}] + free(json); + } + + // 6. Generate text (adapters are applied automatically) + rac_llm_options_t opts = RAC_LLM_OPTIONS_DEFAULT; + rac_llm_result_t result = {0}; + rac_llm_component_generate(component, "Hello, world!", &opts, &result); + printf("Response: %s\n", result.text); + rac_llm_result_free(&result); + + // 7. Remove one adapter + rac_llm_component_remove_lora(component, "/path/to/adapter.gguf"); + + // 8. Clear all adapters + rac_llm_component_clear_lora(component); + + // 9. Cleanup + rac_llm_component_destroy(component); + rac_shutdown(); + return 0; +} +``` + +### Usage Example (Swift -- iOS SDK pattern) + +For Swift SDK implementers, the pattern would be: + +```swift +// The C functions are imported via CRACommons module +import CRACommons + +// Load adapter +let result = rac_llm_component_load_lora(componentHandle, path, scale) +guard result == RAC_SUCCESS else { + throw SDKError.llm("LoRA load failed: \(rac_error_message(result))") +} + +// Query adapters +var jsonPtr: UnsafeMutablePointer? = nil +rac_llm_component_get_lora_info(componentHandle, &jsonPtr) +if let json = jsonPtr { + let jsonString = String(cString: json) + free(json) + // Parse JSON string into Swift structs +} +``` + +### Return Codes Reference + +| Code | Constant | Meaning | +|------|----------|---------| +| 0 | `RAC_SUCCESS` | Operation succeeded | +| -1 | `RAC_ERROR_INVALID_HANDLE` | NULL or invalid component handle | +| -2 | `RAC_ERROR_INVALID_ARGUMENT` | NULL adapter_path | +| -236 | `RAC_ERROR_NOT_SUPPORTED` | Backend does not implement LoRA (vtable entry is NULL) | +| -230 | `RAC_ERROR_COMPONENT_NOT_READY` | No model loaded | +| -110 | `RAC_ERROR_MODEL_NOT_FOUND` | Adapter file path doesn't exist | +| -600+ | Backend-specific | Duplicate path, incompatible adapter, context recreation failure | + +--- + +## Architecture + +### Layer Diagram + +``` +Kotlin Public API (RunAnywhere.loadLoraAdapter) + | + v +Kotlin Bridge (CppBridgeLLM.loadLoraAdapter) + | + v +JNI Native (RunAnywhereBridge.racLlmComponentLoadLora) + | + v +Component C API (rac_llm_component_load_lora) + | + v [vtable dispatch: llm_service->ops->load_lora()] +Service Vtable (rac_llm_service_ops_t) + | + v +Backend C API (rac_llm_llamacpp_load_lora) + | + v +C++ Internal (LlamaCppTextGeneration::load_lora_adapter) + | + v +llama.cpp API (llama_adapter_lora_init + llama_set_adapter_lora) +``` + +Each layer only talks to the one directly below it. No layer skips. + +### Vtable Dispatch + +The component layer (`llm_component.cpp`) does NOT directly call backend-specific +functions. Instead, it dispatches through the `rac_llm_service_ops_t` vtable: + +```c +// Component dispatches through vtable (backend-agnostic) +auto* llm_service = reinterpret_cast(service); +if (!llm_service->ops || !llm_service->ops->load_lora) + return RAC_ERROR_NOT_SUPPORTED; +return llm_service->ops->load_lora(llm_service->impl, adapter_path, scale); +``` + +The llamacpp backend registers its LoRA vtable entries during service creation +in `rac_backend_llamacpp_register.cpp`. Backends that do not support LoRA leave +these pointers as NULL, and the component returns `RAC_ERROR_NOT_SUPPORTED`. + +This keeps `librac_commons.so` decoupled from `librac_backend_llamacpp.so`. + +--- + +## llama.cpp LoRA API (b8011) + +The implementation uses these llama.cpp functions: + +| Function | Purpose | +|----------|---------| +| `llama_adapter_lora_init(model, path)` | Load adapter tensors from GGUF file | +| `llama_set_adapter_lora(ctx, adapter, scale)` | Apply adapter to context with scale | +| `llama_rm_adapter_lora(ctx, adapter)` | Remove specific adapter from context | +| `llama_clear_adapter_lora(ctx)` | Remove all adapters from context | +| `llama_memory_clear(memory, true)` | Clear KV cache after adapter changes | + +Note: `llama_adapter_lora_free()` is deprecated. Adapters are freed automatically +when the model is freed. + +--- + +## Optimizations and Design Decisions + +### Context Recreation + +llama.cpp requires all adapters to be loaded before context creation. When a new +adapter is loaded after the model is already running (context exists), the +implementation recreates the context: + +1. Free old context and sampler +2. Create new context with same parameters (context_size, num_threads) +3. Rebuild sampler chain (temperature, top_p, top_k, repetition penalty) +4. Re-apply ALL loaded adapters to the new context +5. Clear KV cache + +This is handled by `recreate_context()` + `apply_lora_adapters()` in +`llamacpp_backend.cpp`. The approach keeps things simple while ensuring +correctness -- adapter memory overhead is typically 1-5% of the base model, +so the cost of re-applying all adapters is negligible. + +### KV Cache Invalidation + +After any adapter change (load, remove, clear), the KV cache is always +cleared via `llama_memory_clear(llama_get_memory(context_), true)`. This is +mandatory because cached key-value pairs were computed with the previous +adapter configuration and would produce incorrect results. + +### Thread Safety + +All LoRA operations acquire the same mutex (`mtx_`) used by the text generation +inference loop. This guarantees that adapters are never modified while inference +is in progress. The lock hierarchy is: + +- C++ layer: `std::lock_guard` on `mtx_` (already used by generate) +- Component layer: `std::lock_guard` on `component->mtx` +- Kotlin bridge layer: `synchronized(lock)` on the CppBridgeLLM lock object + +### Duplicate Detection + +`load_lora_adapter()` checks for duplicate adapter paths before loading. If the +same path is already loaded, it returns an error instead of loading twice. + +### Rollback on Failure + +If context recreation fails after an adapter is loaded, the adapter entry is +popped from the `lora_adapters_` vector. Same if `apply_lora_adapters()` fails. +This prevents the tracking vector from going out of sync with actual context +state. + +### Adapter Memory Lifecycle + +Adapters are stored in a `std::vector` on the +`LlamaCppTextGeneration` instance. When `unload_model_internal()` is called, +adapters are cleared from the context first, then the vector is cleared, then +the context and model are freed. This ordering prevents use-after-free. + +--- + +## Files Changed + +### Layer 1: C++ Internal + +| File | Changes | +|------|---------| +| `sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.h` | Added `LoraAdapterEntry` struct, 4 public methods (`load_lora_adapter`, `remove_lora_adapter`, `clear_lora_adapters`, `get_lora_info`), 2 private helpers (`recreate_context`, `apply_lora_adapters`), `lora_adapters_` vector member | +| `sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.cpp` | Implemented 6 new methods. Modified `unload_model_internal()` to clear adapters before freeing context/model | + +### Layer 2: Backend C API + +| File | Changes | +|------|---------| +| `sdk/runanywhere-commons/include/rac/backends/rac_llm_llamacpp.h` | Added 4 C function declarations: `rac_llm_llamacpp_load_lora`, `rac_llm_llamacpp_remove_lora`, `rac_llm_llamacpp_clear_lora`, `rac_llm_llamacpp_get_lora_info` | +| `sdk/runanywhere-commons/src/backends/llamacpp/rac_llm_llamacpp.cpp` | Implemented 4 C functions. Pattern: validate handle, cast to impl, call C++ method, return result | + +### Layer 3: Vtable + Component Wrappers + +| File | Changes | +|------|---------| +| `sdk/runanywhere-commons/include/rac/features/llm/rac_llm_service.h` | Added 4 optional LoRA function pointers to `rac_llm_service_ops_t` vtable: `load_lora`, `remove_lora`, `clear_lora`, `get_lora_info` | +| `sdk/runanywhere-commons/include/rac/features/llm/rac_llm_component.h` | Added 4 component-level function declarations | +| `sdk/runanywhere-commons/src/features/llm/llm_component.cpp` | Implemented 4 component functions. Dispatches through vtable with NULL checks (returns `RAC_ERROR_NOT_SUPPORTED` if backend doesn't implement LoRA) | +| `sdk/runanywhere-commons/src/backends/llamacpp/rac_backend_llamacpp_register.cpp` | Added 4 vtable wrapper functions and wired them into `g_llamacpp_ops` | + +### Layer 4: JNI Bridge + +| File | Changes | +|------|---------| +| `sdk/runanywhere-commons/src/jni/runanywhere_commons_jni.cpp` | Added 4 JNI functions: `racLlmComponentLoadLora`, `racLlmComponentRemoveLora`, `racLlmComponentClearLora`, `racLlmComponentGetLoraInfo` | + +### Layer 5: Kotlin Bridge + +| File | Changes | +|------|---------| +| `sdk/runanywhere-kotlin/src/jvmAndroidMain/.../RunAnywhereBridge.kt` | Added 4 `external` JNI method declarations | +| `sdk/runanywhere-kotlin/src/jvmAndroidMain/.../CppBridgeLLM.kt` | Added 4 bridge methods with synchronized access, state validation, and logging | + +### Layer 6: Kotlin Public API + +| File | Changes | +|------|---------| +| `sdk/runanywhere-kotlin/src/commonMain/.../LLMTypes.kt` | Added `LoRAAdapterConfig` and `LoRAAdapterInfo` data classes | +| `sdk/runanywhere-kotlin/src/commonMain/.../RunAnywhere+LoRA.kt` | NEW file. `expect` declarations for 4 public API functions | +| `sdk/runanywhere-kotlin/src/jvmAndroidMain/.../RunAnywhere+LoRA.jvmAndroid.kt` | NEW file. `actual` implementations with init checks, CppBridgeLLM delegation, JSON parsing for adapter info | + +--- + +## How to Extend + +### Adding a new LoRA operation + +Follow the same 6-layer pattern: + +1. Add C++ method to `LlamaCppTextGeneration` in `llamacpp_backend.h/.cpp` +2. Add C function to `rac_llm_llamacpp.h/.cpp` +3. Add vtable entry to `rac_llm_service_ops_t` in `rac_llm_service.h` +4. Wire vtable entry in `rac_backend_llamacpp_register.cpp` +5. Add component wrapper to `rac_llm_component.h` / `llm_component.cpp` (dispatch through vtable) +6. Add JNI function to `runanywhere_commons_jni.cpp` +7. Add external declaration to `RunAnywhereBridge.kt`, bridge method to `CppBridgeLLM.kt` +8. Add expect/actual declarations to `RunAnywhere+LoRA.kt` / `RunAnywhere+LoRA.jvmAndroid.kt` + +### Adding scale adjustment without reload + +Could be done by calling `llama_set_adapter_lora(ctx, adapter, new_scale)` +directly without context recreation. Would need a new method at each layer. + +--- + +## Build Verification + +Android native build (confirmed passing): +```bash +cd sdk/runanywhere-commons +./scripts/build-android.sh +``` + +C++ desktop build (confirmed passing): +```bash +cd sdk/runanywhere-commons +cmake -B build/dev -DRAC_BUILD_BACKENDS=ON -DRAC_BUILD_JNI=ON +cmake --build build/dev +``` + +After Android build, copy `.so` files to jniLibs: +```bash +DIST=sdk/runanywhere-commons/dist/android +JNILIBS=sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp/src/androidMain/jniLibs/arm64-v8a +/usr/bin/cp $DIST/llamacpp/arm64-v8a/librac_backend_llamacpp.so $JNILIBS/ +/usr/bin/cp $DIST/llamacpp/arm64-v8a/librac_backend_llamacpp_jni.so $JNILIBS/ +/usr/bin/cp $DIST/llamacpp/arm64-v8a/librac_commons.so $JNILIBS/ +/usr/bin/cp $DIST/llamacpp/arm64-v8a/libc++_shared.so $JNILIBS/ +/usr/bin/cp $DIST/llamacpp/arm64-v8a/libomp.so $JNILIBS/ +/usr/bin/cp $DIST/jni/arm64-v8a/librunanywhere_jni.so $JNILIBS/ +``` + +Kotlin build: +```bash +cd sdk/runanywhere-kotlin +./scripts/sdk.sh build +``` + +--- + +## Changelog + +| Date | Author | Description | +|------|--------|-------------| +| 2026-02-19 | Claude | Initial implementation of LoRA adapter support across all 6 layers (C++ through Kotlin public API). C++ desktop build verified. | +| 2026-02-19 | Claude | Fixed architecture: Component layer now dispatches LoRA ops through vtable (`rac_llm_service_ops_t`) instead of calling backend directly. This decouples `librac_commons.so` from `librac_backend_llamacpp.so`. Added 4 vtable entries and wrapper functions. Fixed `AttachCurrentThread` cast for Android NDK C++ build. Android native build verified. | +| 2026-02-19 | Claude | Added detailed Kotlin SDK usage guide with data types, code examples, error handling, Android ViewModel pattern, and table of contents with section links. Updated "How to Extend" to include vtable step. | diff --git a/examples/android/RunAnyWhereLora/.gitignore b/examples/android/RunAnyWhereLora/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/examples/android/RunAnyWhereLora/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/examples/android/RunAnyWhereLora/.idea/.gitignore b/examples/android/RunAnyWhereLora/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/examples/android/RunAnyWhereLora/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/examples/android/RunAnyWhereLora/.idea/.name b/examples/android/RunAnyWhereLora/.idea/.name new file mode 100644 index 000000000..16e1c7c63 --- /dev/null +++ b/examples/android/RunAnyWhereLora/.idea/.name @@ -0,0 +1 @@ +RunAnyWhere-Lora \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/.idea/AndroidProjectSystem.xml b/examples/android/RunAnyWhereLora/.idea/AndroidProjectSystem.xml new file mode 100644 index 000000000..4a53bee8c --- /dev/null +++ b/examples/android/RunAnyWhereLora/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/.idea/gradle.xml b/examples/android/RunAnyWhereLora/.idea/gradle.xml new file mode 100644 index 000000000..7505d8d39 --- /dev/null +++ b/examples/android/RunAnyWhereLora/.idea/gradle.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/.idea/misc.xml b/examples/android/RunAnyWhereLora/.idea/misc.xml new file mode 100644 index 000000000..c2b3ddced --- /dev/null +++ b/examples/android/RunAnyWhereLora/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/.idea/runConfigurations.xml b/examples/android/RunAnyWhereLora/.idea/runConfigurations.xml new file mode 100644 index 000000000..16660f1d8 --- /dev/null +++ b/examples/android/RunAnyWhereLora/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/.gitignore b/examples/android/RunAnyWhereLora/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/build.gradle.kts b/examples/android/RunAnyWhereLora/app/build.gradle.kts new file mode 100644 index 000000000..2a4100bb1 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/build.gradle.kts @@ -0,0 +1,120 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.runanywhere.run_anywhere_lora" + compileSdk = 36 + + defaultConfig { + applicationId = "com.runanywhere.run_anywhere_lora" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + 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/**", + "**/kotlin/**", + "kotlin/**", + "META-INF/kotlin/**", + "META-INF/*.kotlin_module", + "META-INF/INDEX.LIST", + ) + } + jniLibs { + useLegacyPackaging = true + pickFirsts += listOf("lib/**/*.so") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs += listOf( + "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + ) + } + + buildFeatures { + compose = true + } +} + +dependencies { + // RunAnywhere SDK + LlamaCPP backend + implementation(project(":runanywhere-kotlin")) + implementation(project(":runanywhere-core-llamacpp")) + + // AndroidX Core & Lifecycle + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + // 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) + + // Coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + // Testing + testImplementation(libs.junit) + 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()) } + } + } +} diff --git a/examples/android/RunAnyWhereLora/app/proguard-rules.pro b/examples/android/RunAnyWhereLora/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/androidTest/java/com/runanywhere/run_anywhere_lora/ExampleInstrumentedTest.kt b/examples/android/RunAnyWhereLora/app/src/androidTest/java/com/runanywhere/run_anywhere_lora/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..089fdfb76 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/androidTest/java/com/runanywhere/run_anywhere_lora/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.runanywhere.run_anywhere_lora + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * 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.run_anywhere_lora", appContext.packageName) + } +} \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/AndroidManifest.xml b/examples/android/RunAnyWhereLora/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7b264f3b7 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraApplication.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraApplication.kt new file mode 100644 index 000000000..705017e38 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraApplication.kt @@ -0,0 +1,72 @@ +package com.runanywhere.run_anywhere_lora + +import android.app.Application +import android.util.Log +import com.runanywhere.sdk.llm.llamacpp.LlamaCPP +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.SDKEnvironment +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 + +sealed class SDKInitState { + data object Loading : SDKInitState() + data object Ready : SDKInitState() + data class Error(val error: Throwable) : SDKInitState() +} + +class LoraApplication : Application() { + + companion object { + private const val TAG = "LoraApp" + } + + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _initializationState = MutableStateFlow(SDKInitState.Loading) + val initializationState: StateFlow = _initializationState.asStateFlow() + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "App launched, initializing SDK...") + applicationScope.launch(Dispatchers.IO) { + delay(200) + initializeSDK() + } + } + + override fun onTerminate() { + applicationScope.cancel() + super.onTerminate() + } + + private suspend fun initializeSDK() { + try { + AndroidPlatformContext.initialize(this@LoraApplication) + + RunAnywhere.initialize(environment = SDKEnvironment.DEVELOPMENT) + Log.i(TAG, "SDK initialized in DEVELOPMENT mode") + + kotlinx.coroutines.runBlocking { + RunAnywhere.completeServicesInitialization() + } + Log.i(TAG, "SDK services initialization complete") + + LlamaCPP.register(priority = 100) + Log.i(TAG, "LlamaCPP backend registered") + + _initializationState.value = SDKInitState.Ready + Log.i(TAG, "SDK ready") + } catch (e: Exception) { + Log.e(TAG, "SDK initialization failed: ${e.message}", e) + _initializationState.value = SDKInitState.Error(e) + } + } +} diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraScreen.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraScreen.kt new file mode 100644 index 000000000..b9f76c556 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraScreen.kt @@ -0,0 +1,530 @@ +package com.runanywhere.run_anywhere_lora + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LoraScreen(viewModel: LoraViewModel = viewModel()) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scrollState = rememberScrollState() + + // Track pending LoRA file path for scale dialog + var pendingLoraPath by remember { mutableStateOf(null) } + var loraScale by remember { mutableFloatStateOf(1.0f) } + + // Storage permission state + var hasStoragePermission by remember { + mutableStateOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + true + } + ) + } + + // Permission launcher for Android 11+ + val storagePermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { + hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + true + } + } + + // Legacy permission launcher for Android < 11 + val legacyPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + hasStoragePermission = granted + } + + // File picker for model + val modelFilePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + uri?.let { resolveFilePath(context, it) }?.let { path -> + viewModel.loadModel(path) + } + } + + // File picker for LoRA adapter + val loraFilePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + uri?.let { resolveFilePath(context, it) }?.let { path -> + pendingLoraPath = path + } + } + + // Show errors as snackbar + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + // Auto-scroll when answer updates + LaunchedEffect(state.answer) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + // LoRA scale dialog + if (pendingLoraPath != null) { + LoraScaleDialog( + filename = pendingLoraPath!!.substringAfterLast('/'), + scale = loraScale, + onScaleChange = { loraScale = it }, + onConfirm = { + viewModel.loadLoraAdapter(pendingLoraPath!!, loraScale) + pendingLoraPath = null + loraScale = 1.0f + }, + onDismiss = { + pendingLoraPath = null + loraScale = 1.0f + }, + ) + } + + fun requestStoragePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.parse("package:${context.packageName}") + } + storagePermissionLauncher.launch(intent) + } else { + legacyPermissionLauncher.launch(android.Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("RunAnywhere LoRA") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .imePadding(), + ) { + // Status section + StatusSection(state) + + // Response card (fills available space) + Card( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 12.dp, vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + ) { + if (state.answer.isEmpty() && !state.isGenerating) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (state.modelLoaded) { + "Ask a question below" + } else { + "Load a model to get started" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } else { + SelectionContainer( + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState), + ) { + Text( + text = state.answer, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Metrics row + state.metrics?.let { metrics -> + HorizontalDivider( + modifier = Modifier.padding(vertical = 6.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "%.1f tok/s".format(metrics.tokensPerSecond), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + Text( + text = "${metrics.totalTokens} tokens", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + Text( + text = "%.1fs".format(metrics.latencyMs / 1000.0), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + + // Loading indicator + if (state.isGenerating && state.answer.isEmpty()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } + } + } + } + + // Bottom section: action chips + input + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + // Action chips row + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip( + onClick = { + if (!hasStoragePermission) { + requestStoragePermission() + } else { + modelFilePicker.launch(arrayOf("*/*")) + } + }, + label = { Text("Model", maxLines = 1) }, + leadingIcon = { + if (state.modelLoading) { + CircularProgressIndicator( + modifier = Modifier.size(AssistChipDefaults.IconSize), + strokeWidth = 2.dp, + ) + } else { + Icon( + Icons.Default.FolderOpen, + contentDescription = "Load model", + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + } + }, + enabled = !state.modelLoading, + ) + + AssistChip( + onClick = { + if (!hasStoragePermission) { + requestStoragePermission() + } else { + loraFilePicker.launch(arrayOf("*/*")) + } + }, + label = { Text("LoRA", maxLines = 1) }, + leadingIcon = { + Icon( + Icons.Default.Add, + contentDescription = "Load LoRA", + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + enabled = state.modelLoaded && !state.isGenerating, + ) + + AnimatedVisibility(visible = state.loraAdapters.isNotEmpty()) { + AssistChip( + onClick = { viewModel.clearLoraAdapters() }, + label = { Text("Clear", maxLines = 1) }, + leadingIcon = { + Icon( + Icons.Default.Clear, + contentDescription = "Clear LoRA", + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + enabled = !state.isGenerating, + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Input row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + ) { + OutlinedTextField( + value = state.question, + onValueChange = { viewModel.updateQuestion(it) }, + modifier = Modifier.weight(1f), + placeholder = { Text("Ask a question...") }, + maxLines = 3, + shape = MaterialTheme.shapes.medium, + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + if (state.isGenerating) { + viewModel.cancelGeneration() + } else { + viewModel.askQuestion() + } + }, + enabled = state.modelLoaded, + ) { + Icon( + imageVector = if (state.isGenerating) Icons.Default.Stop else Icons.Default.Send, + contentDescription = if (state.isGenerating) "Stop" else "Send", + tint = if (state.modelLoaded) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } + } + } + } +} + +@Composable +private fun StatusSection(state: LoraUiState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + // Model status + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Model: ", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (state.modelLoading) { + Text( + text = "Loading...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } else if (state.modelPath != null) { + Text( + text = state.modelPath.substringAfterLast('/'), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = "None", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } + } + + // LoRA adapters status + if (state.loraAdapters.isNotEmpty()) { + for (adapter in state.loraAdapters) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "LoRA: ", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "${adapter.path.substringAfterLast('/')} x${adapter.scale}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun LoraScaleDialog( + filename: String, + scale: Float, + onScaleChange: (Float) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Load LoRA Adapter") }, + text = { + Column { + Text( + text = filename, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Scale: %.2f".format(scale), + style = MaterialTheme.typography.labelMedium, + ) + Slider( + value = scale, + onValueChange = onScaleChange, + valueRange = 0f..2f, + steps = 19, + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text("Load") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** + * Resolve a content URI to a real file path. + * With MANAGE_EXTERNAL_STORAGE, we can access files directly. + */ +private fun resolveFilePath(context: android.content.Context, uri: Uri): String? { + // Try to get the file path from the URI directly + if (uri.scheme == "file") { + return uri.path + } + + // For content:// URIs, try to resolve via cursor + try { + context.contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex("_data") + if (idx >= 0) { + val path = cursor.getString(idx) + if (path != null) return path + } + } + } + } catch (_: Exception) { + // Fall through to copy approach + } + + // Fallback: copy to app cache and return that path + try { + val filename = uri.lastPathSegment?.substringAfterLast('/') ?: "model.gguf" + val cacheFile = java.io.File(context.cacheDir, filename) + context.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> + input.copyTo(output) + } + } + return cacheFile.absolutePath + } catch (e: Exception) { + android.util.Log.e("LoraScreen", "Failed to resolve file path: ${e.message}", e) + return null + } +} diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraViewModel.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraViewModel.kt new file mode 100644 index 000000000..7950460a5 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraViewModel.kt @@ -0,0 +1,235 @@ +package com.runanywhere.run_anywhere_lora + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runanywhere.sdk.core.types.InferenceFramework +import com.runanywhere.sdk.foundation.bridge.extensions.CppBridgeModelRegistry +import com.runanywhere.sdk.public.RunAnywhere +import com.runanywhere.sdk.public.extensions.LLM.LLMGenerationOptions +import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterConfig +import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterInfo +import com.runanywhere.sdk.public.extensions.cancelGeneration +import com.runanywhere.sdk.public.extensions.clearLoraAdapters +import com.runanywhere.sdk.public.extensions.generateStreamWithMetrics +import com.runanywhere.sdk.public.extensions.getLoadedLoraAdapters +import com.runanywhere.sdk.public.extensions.isLLMModelLoaded +import com.runanywhere.sdk.public.extensions.loadLLMModel +import com.runanywhere.sdk.public.extensions.loadLoraAdapter +import com.runanywhere.sdk.public.extensions.registerModel +import com.runanywhere.sdk.public.extensions.removeLoraAdapter +import com.runanywhere.sdk.public.extensions.unloadLLMModel +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 + +data class GenerationMetrics( + val tokensPerSecond: Double, + val totalTokens: Int, + val latencyMs: Double, +) + +data class LoraUiState( + val modelPath: String? = null, + val modelLoaded: Boolean = false, + val modelLoading: Boolean = false, + val loraAdapters: List = emptyList(), + val question: String = "", + val answer: String = "", + val isGenerating: Boolean = false, + val metrics: GenerationMetrics? = null, + val error: String? = null, +) + +class LoraViewModel : ViewModel() { + + companion object { + private const val TAG = "LoraVM" + } + + private val _uiState = MutableStateFlow(LoraUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var generationJob: Job? = null + + fun updateQuestion(text: String) { + _uiState.update { it.copy(question = text) } + } + + fun clearError() { + _uiState.update { it.copy(error = null) } + } + + fun loadModel(path: String) { + viewModelScope.launch { + _uiState.update { it.copy(modelLoading = true, error = null) } + try { + // Unload existing model if loaded + if (RunAnywhere.isLLMModelLoaded()) { + RunAnywhere.unloadLLMModel() + } + + // Generate a model ID from filename + val filename = path.substringAfterLast('/') + val modelId = filename.removeSuffix(".gguf") + + // Register the model in the SDK registry + RunAnywhere.registerModel( + id = modelId, + name = filename, + url = "file://$path", + framework = InferenceFramework.LLAMA_CPP, + ) + + // Tell the C++ registry the file is already local at this path + CppBridgeModelRegistry.updateDownloadStatus(modelId, path) + + // Load the model + withContext(Dispatchers.IO) { + RunAnywhere.loadLLMModel(modelId) + } + + _uiState.update { + it.copy( + modelPath = path, + modelLoaded = true, + modelLoading = false, + loraAdapters = emptyList(), + ) + } + Log.i(TAG, "Model loaded: $filename") + } catch (e: Exception) { + Log.e(TAG, "Failed to load model: ${e.message}", e) + _uiState.update { + it.copy( + modelLoading = false, + error = "Failed to load model: ${e.message}", + ) + } + } + } + } + + fun loadLoraAdapter(path: String, scale: Float) { + viewModelScope.launch { + _uiState.update { it.copy(error = null) } + try { + withContext(Dispatchers.IO) { + RunAnywhere.loadLoraAdapter(LoRAAdapterConfig(path = path, scale = scale)) + } + refreshAdapters() + Log.i(TAG, "LoRA adapter loaded: ${path.substringAfterLast('/')} (scale=$scale)") + } catch (e: Exception) { + Log.e(TAG, "Failed to load LoRA adapter: ${e.message}", e) + _uiState.update { it.copy(error = "Failed to load LoRA: ${e.message}") } + } + } + } + + fun removeLoraAdapter(path: String) { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + RunAnywhere.removeLoraAdapter(path) + } + refreshAdapters() + } catch (e: Exception) { + _uiState.update { it.copy(error = "Failed to remove LoRA: ${e.message}") } + } + } + } + + fun clearLoraAdapters() { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + RunAnywhere.clearLoraAdapters() + } + _uiState.update { it.copy(loraAdapters = emptyList()) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Failed to clear LoRA: ${e.message}") } + } + } + } + + fun askQuestion() { + val question = _uiState.value.question.trim() + if (question.isEmpty()) return + if (!_uiState.value.modelLoaded) { + _uiState.update { it.copy(error = "Load a model first") } + return + } + + generationJob?.cancel() + generationJob = viewModelScope.launch { + _uiState.update { + it.copy( + answer = "", + isGenerating = true, + metrics = null, + error = null, + ) + } + + try { + val result = withContext(Dispatchers.IO) { + RunAnywhere.generateStreamWithMetrics( + prompt = question, + options = LLMGenerationOptions( + maxTokens = 1024, + temperature = 0.7f, + ), + ) + } + + // Collect streaming tokens + result.stream.collect { token -> + _uiState.update { it.copy(answer = it.answer + token) } + } + + // Get final metrics + val finalResult = result.result.await() + _uiState.update { + it.copy( + isGenerating = false, + metrics = GenerationMetrics( + tokensPerSecond = finalResult.tokensPerSecond, + totalTokens = finalResult.tokensUsed, + latencyMs = finalResult.latencyMs, + ), + ) + } + } catch (e: Exception) { + Log.e(TAG, "Generation failed: ${e.message}", e) + _uiState.update { + it.copy( + isGenerating = false, + error = "Generation failed: ${e.message}", + ) + } + } + } + } + + fun cancelGeneration() { + generationJob?.cancel() + RunAnywhere.cancelGeneration() + _uiState.update { it.copy(isGenerating = false) } + } + + private suspend fun refreshAdapters() { + try { + val adapters = withContext(Dispatchers.IO) { + RunAnywhere.getLoadedLoraAdapters() + } + _uiState.update { it.copy(loraAdapters = adapters) } + } catch (e: Exception) { + Log.e(TAG, "Failed to refresh adapters: ${e.message}", e) + } + } +} diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/MainActivity.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/MainActivity.kt new file mode 100644 index 000000000..d06422964 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/MainActivity.kt @@ -0,0 +1,85 @@ +package com.runanywhere.run_anywhere_lora + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.runanywhere.run_anywhere_lora.ui.theme.RunAnyWhereLoraTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + RunAnyWhereLoraTheme { + val app = application as LoraApplication + val sdkState by app.initializationState.collectAsState() + when (sdkState) { + is SDKInitState.Loading -> LoadingIntroScreen() + is SDKInitState.Error -> ErrorScreen( + error = (sdkState as SDKInitState.Error).error, + ) + is SDKInitState.Ready -> LoraScreen() + } + } + } + } +} + +@Composable +private fun LoadingIntroScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Initializing SDK...", + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} +@Composable +private fun ErrorScreen(error: Throwable) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "SDK initialization failed", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error.message ?: "Unknown error", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { /* Would need app reference to retry */ }) { + Text("Retry") + } + } + } +} diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Color.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Color.kt new file mode 100644 index 000000000..7ccee9fa0 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.runanywhere.run_anywhere_lora.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Theme.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Theme.kt new file mode 100644 index 000000000..14830842d --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.runanywhere.run_anywhere_lora.ui.theme + +import android.app.Activity +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.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun RunAnyWhereLoraTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + 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 + ) +} \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Type.kt b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Type.kt new file mode 100644 index 000000000..ef93ea6e2 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.runanywhere.run_anywhere_lora.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 + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_background.xml b/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_foreground.xml b/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/values/colors.xml b/examples/android/RunAnyWhereLora/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/values/strings.xml b/examples/android/RunAnyWhereLora/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..af52595ad --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + RunAnyWhere-Lora + \ No newline at end of file diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/values/themes.xml b/examples/android/RunAnyWhereLora/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..5d82f6825 --- /dev/null +++ b/examples/android/RunAnyWhereLora/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +