diff --git a/.idea/runConfigurations/01_Build_Full_SDK__C____Kotlin_.xml b/.idea/runConfigurations/01_Build_Full_SDK__C____Kotlin_.xml
new file mode 100644
index 000000000..86bdf524a
--- /dev/null
+++ b/.idea/runConfigurations/01_Build_Full_SDK__C____Kotlin_.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
diff --git a/.idea/runConfigurations/8_Run_IntelliJ_Plugin.xml b/.idea/runConfigurations/02_Build_C__.xml
similarity index 88%
rename from .idea/runConfigurations/8_Run_IntelliJ_Plugin.xml
rename to .idea/runConfigurations/02_Build_C__.xml
index 0ddac18de..c931f9a47 100644
--- a/.idea/runConfigurations/8_Run_IntelliJ_Plugin.xml
+++ b/.idea/runConfigurations/02_Build_C__.xml
@@ -1,5 +1,5 @@
-
+
@@ -10,7 +10,7 @@
-
+
diff --git a/.idea/runConfigurations/2_Build_SDK.xml b/.idea/runConfigurations/03_Build_Kotlin_SDK.xml
similarity index 88%
rename from .idea/runConfigurations/2_Build_SDK.xml
rename to .idea/runConfigurations/03_Build_Kotlin_SDK.xml
index af183acf3..eb87cde89 100644
--- a/.idea/runConfigurations/2_Build_SDK.xml
+++ b/.idea/runConfigurations/03_Build_Kotlin_SDK.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/04_Copy_Native_Libs.xml b/.idea/runConfigurations/04_Copy_Native_Libs.xml
new file mode 100644
index 000000000..46ffb6f83
--- /dev/null
+++ b/.idea/runConfigurations/04_Copy_Native_Libs.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
diff --git a/.idea/runConfigurations/3_Build_SDK_Release.xml b/.idea/runConfigurations/05_Build_SDK_Release.xml
similarity index 93%
rename from .idea/runConfigurations/3_Build_SDK_Release.xml
rename to .idea/runConfigurations/05_Build_SDK_Release.xml
index 24e1a19f7..aac280b08 100644
--- a/.idea/runConfigurations/3_Build_SDK_Release.xml
+++ b/.idea/runConfigurations/05_Build_SDK_Release.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/5_Build_Android_App.xml b/.idea/runConfigurations/06_Build_Android_App.xml
similarity index 93%
rename from .idea/runConfigurations/5_Build_Android_App.xml
rename to .idea/runConfigurations/06_Build_Android_App.xml
index 574b48f24..c495a08ca 100644
--- a/.idea/runConfigurations/5_Build_Android_App.xml
+++ b/.idea/runConfigurations/06_Build_Android_App.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/6_Run_Android_App.xml b/.idea/runConfigurations/07_Run_Android_App.xml
similarity index 93%
rename from .idea/runConfigurations/6_Run_Android_App.xml
rename to .idea/runConfigurations/07_Run_Android_App.xml
index 5e74028f3..42e97f57d 100644
--- a/.idea/runConfigurations/6_Run_Android_App.xml
+++ b/.idea/runConfigurations/07_Run_Android_App.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/4_Publish_SDK_to_Maven_Local.xml b/.idea/runConfigurations/08_Publish_Maven_Local.xml
similarity index 87%
rename from .idea/runConfigurations/4_Publish_SDK_to_Maven_Local.xml
rename to .idea/runConfigurations/08_Publish_Maven_Local.xml
index eff53921e..fa66cecff 100644
--- a/.idea/runConfigurations/4_Publish_SDK_to_Maven_Local.xml
+++ b/.idea/runConfigurations/08_Publish_Maven_Local.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/7_Build_IntelliJ_Plugin.xml b/.idea/runConfigurations/09_Build_IntelliJ_Plugin.xml
similarity index 93%
rename from .idea/runConfigurations/7_Build_IntelliJ_Plugin.xml
rename to .idea/runConfigurations/09_Build_IntelliJ_Plugin.xml
index 97c4bd471..6ea4731ae 100644
--- a/.idea/runConfigurations/7_Build_IntelliJ_Plugin.xml
+++ b/.idea/runConfigurations/09_Build_IntelliJ_Plugin.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/10_Run_IntelliJ_Plugin.xml b/.idea/runConfigurations/10_Run_IntelliJ_Plugin.xml
new file mode 100644
index 000000000..b578aa86d
--- /dev/null
+++ b/.idea/runConfigurations/10_Run_IntelliJ_Plugin.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+ false
+
+
+
diff --git a/.idea/runConfigurations/1_Setup.xml b/.idea/runConfigurations/11_Setup.xml
similarity index 89%
rename from .idea/runConfigurations/1_Setup.xml
rename to .idea/runConfigurations/11_Setup.xml
index cb6e4e1dc..a1e059b1e 100644
--- a/.idea/runConfigurations/1_Setup.xml
+++ b/.idea/runConfigurations/11_Setup.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/9_Build_All.xml b/.idea/runConfigurations/12_Build_All.xml
similarity index 88%
rename from .idea/runConfigurations/9_Build_All.xml
rename to .idea/runConfigurations/12_Build_All.xml
index 116ad5864..6119051ec 100644
--- a/.idea/runConfigurations/9_Build_All.xml
+++ b/.idea/runConfigurations/12_Build_All.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/runConfigurations/10_Clean_All.xml b/.idea/runConfigurations/13_Clean_All.xml
similarity index 93%
rename from .idea/runConfigurations/10_Clean_All.xml
rename to .idea/runConfigurations/13_Clean_All.xml
index ed9f83d6d..1d66ffe5a 100644
--- a/.idea/runConfigurations/10_Clean_All.xml
+++ b/.idea/runConfigurations/13_Clean_All.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 544725c32..dcb6b8c4c 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,10 +2,5 @@
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/Package.swift b/Package.swift
index 95eabe3d6..9eb8502f1 100644
--- a/Package.swift
+++ b/Package.swift
@@ -252,11 +252,10 @@ func ragProducts() -> [Product] {
}
/// RAG dependency for the RunAnywhere core target
+/// NOTE: Core already accesses RAG C headers via CRACommons umbrella (rac_rag.h, rac_rag_pipeline.h).
+/// No additional dependency needed — RAGBackend is only used by RAGRuntime.
func ragCoreDependencies() -> [Target.Dependency] {
- guard useLocalBinaries || ragRemoteBinaryAvailable else { return [] }
- return [
- "RAGBackend",
- ]
+ return []
}
/// RAG-related targets (C bridge + Swift runtime)
@@ -276,6 +275,8 @@ func ragTargets() -> [Target] {
dependencies: [
"RunAnywhere",
"RAGBackend",
+ "ONNXRuntime",
+ "LlamaCPPRuntime",
],
path: "sdk/runanywhere-swift/Sources/RAGRuntime",
exclude: ["include"],
@@ -381,4 +382,4 @@ func binaryTargets() -> [Target] {
return targets
}
-}
\ No newline at end of file
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 6e12dda79..c365aa209 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,13 +2,26 @@
//
// Available tasks:
// ./gradlew setup - Check environment and create local.properties
+//
+// Native (C++):
+// ./gradlew buildCpp - Build C++ and copy .so to jniLibs
+// ./gradlew buildFullSdk - Full pipeline: C++ + copy + Kotlin SDK
+// ./gradlew copyNativeLibs - Copy .so from dist/ to jniLibs/ (no rebuild)
+//
+// Kotlin SDK:
// ./gradlew buildSdk - Build SDK (debug AAR + JVM JAR)
// ./gradlew buildSdkRelease - Build SDK (release AAR)
+// ./gradlew publishSdkToMavenLocal - Publish SDK to ~/.m2
+//
+// Android App:
// ./gradlew buildAndroidApp - Build Android example app
// ./gradlew runAndroidApp - Build, install, and launch Android app
+//
+// IntelliJ Plugin:
// ./gradlew buildIntellijPlugin - Build IntelliJ plugin
// ./gradlew runIntellijPlugin - Run IntelliJ plugin in sandbox
-// ./gradlew publishSdkToMavenLocal - Publish SDK to ~/.m2
+//
+// Utility:
// ./gradlew buildAll - Build everything
// ./gradlew cleanAll - Clean everything
@@ -126,7 +139,55 @@ tasks.register("setup") {
}
}
+// =============================================================================
+// Native (C++) tasks — wraps build-sdk.sh for IDE integration
+// =============================================================================
+
+tasks.register("buildCpp") {
+ group = "native"
+ description = "Build C++ (runanywhere-commons) and copy .so to jniLibs"
+
+ doLast {
+ val ndkHome = resolveNdkHome(resolveAndroidHome())
+ exec {
+ workingDir = file("sdk/runanywhere-kotlin")
+ environment("ANDROID_NDK_HOME", ndkHome)
+ commandLine("bash", "scripts/build-sdk.sh", "--cpp-only")
+ }
+ }
+}
+
+tasks.register("buildFullSdk") {
+ group = "native"
+ description = "Full pipeline: build C++ + copy .so + build Kotlin SDK"
+
+ doLast {
+ val ndkHome = resolveNdkHome(resolveAndroidHome())
+ exec {
+ workingDir = file("sdk/runanywhere-kotlin")
+ environment("ANDROID_NDK_HOME", ndkHome)
+ commandLine("bash", "scripts/build-sdk.sh")
+ }
+ }
+}
+
+tasks.register("copyNativeLibs") {
+ group = "native"
+ description = "Copy .so from dist/ to jniLibs/ (no C++ rebuild)"
+
+ doLast {
+ val ndkHome = resolveNdkHome(resolveAndroidHome())
+ exec {
+ workingDir = file("sdk/runanywhere-kotlin")
+ environment("ANDROID_NDK_HOME", ndkHome)
+ commandLine("bash", "scripts/build-kotlin.sh", "--local", "--skip-build")
+ }
+ }
+}
+
+// =============================================================================
// SDK tasks
+// =============================================================================
tasks.register("buildSdk") {
group = "sdk"
diff --git a/examples/android/RunAnyWhereLora/.gitignore b/examples/android/RunAnyWhereLora/.gitignore
deleted file mode 100644
index aa724b770..000000000
--- a/examples/android/RunAnyWhereLora/.gitignore
+++ /dev/null
@@ -1,15 +0,0 @@
-*.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
deleted file mode 100644
index 26d33521a..000000000
--- a/examples/android/RunAnyWhereLora/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/examples/android/RunAnyWhereLora/.idea/.name b/examples/android/RunAnyWhereLora/.idea/.name
deleted file mode 100644
index 16e1c7c63..000000000
--- a/examples/android/RunAnyWhereLora/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-RunAnyWhere-Lora
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/.idea/AndroidProjectSystem.xml b/examples/android/RunAnyWhereLora/.idea/AndroidProjectSystem.xml
deleted file mode 100644
index 4a53bee8c..000000000
--- a/examples/android/RunAnyWhereLora/.idea/AndroidProjectSystem.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/.idea/gradle.xml b/examples/android/RunAnyWhereLora/.idea/gradle.xml
deleted file mode 100644
index 7505d8d39..000000000
--- a/examples/android/RunAnyWhereLora/.idea/gradle.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/.idea/misc.xml b/examples/android/RunAnyWhereLora/.idea/misc.xml
deleted file mode 100644
index c2b3ddced..000000000
--- a/examples/android/RunAnyWhereLora/.idea/misc.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/.idea/runConfigurations.xml b/examples/android/RunAnyWhereLora/.idea/runConfigurations.xml
deleted file mode 100644
index 16660f1d8..000000000
--- a/examples/android/RunAnyWhereLora/.idea/runConfigurations.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/app/.gitignore b/examples/android/RunAnyWhereLora/app/.gitignore
deleted file mode 100644
index 42afabfd2..000000000
--- a/examples/android/RunAnyWhereLora/app/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/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
deleted file mode 100644
index 2a4100bb1..000000000
--- a/examples/android/RunAnyWhereLora/app/build.gradle.kts
+++ /dev/null
@@ -1,120 +0,0 @@
-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
deleted file mode 100644
index 481bb4348..000000000
--- a/examples/android/RunAnyWhereLora/app/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# 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
deleted file mode 100644
index 089fdfb76..000000000
--- a/examples/android/RunAnyWhereLora/app/src/androidTest/java/com/runanywhere/run_anywhere_lora/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-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
deleted file mode 100644
index 7b264f3b7..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 705017e38..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraApplication.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-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
deleted file mode 100644
index b9f76c556..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraScreen.kt
+++ /dev/null
@@ -1,530 +0,0 @@
-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
deleted file mode 100644
index 7950460a5..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/LoraViewModel.kt
+++ /dev/null
@@ -1,235 +0,0 @@
-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
deleted file mode 100644
index d06422964..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/MainActivity.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-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
deleted file mode 100644
index 7ccee9fa0..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Color.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-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
deleted file mode 100644
index 14830842d..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Theme.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-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
deleted file mode 100644
index ef93ea6e2..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/java/com/runanywhere/run_anywhere_lora/ui/theme/Type.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index 07d5da9cb..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_background.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 2b068d114..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ 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
deleted file mode 100644
index 6f3b755bf..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ 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
deleted file mode 100644
index 6f3b755bf..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ 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
deleted file mode 100644
index c209e78ec..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null 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
deleted file mode 100644
index b2dfe3d1b..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null 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
deleted file mode 100644
index 4f0f1d64e..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null 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
deleted file mode 100644
index 62b611da0..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null 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
deleted file mode 100644
index 948a3070f..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null 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
deleted file mode 100644
index 1b9a6956b..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null 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
deleted file mode 100644
index 28d4b77f9..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null 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
deleted file mode 100644
index 9287f5083..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null 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
deleted file mode 100644
index aa7d6427e..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null 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
deleted file mode 100644
index 9126ae37c..000000000
Binary files a/examples/android/RunAnyWhereLora/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null 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
deleted file mode 100644
index f8c6127d3..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #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
deleted file mode 100644
index af52595ad..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
- 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
deleted file mode 100644
index 5d82f6825..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/xml/backup_rules.xml b/examples/android/RunAnyWhereLora/app/src/main/res/xml/backup_rules.xml
deleted file mode 100644
index 4df925582..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/xml/backup_rules.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/app/src/main/res/xml/data_extraction_rules.xml b/examples/android/RunAnyWhereLora/app/src/main/res/xml/data_extraction_rules.xml
deleted file mode 100644
index 9ee9997b0..000000000
--- a/examples/android/RunAnyWhereLora/app/src/main/res/xml/data_extraction_rules.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/app/src/test/java/com/runanywhere/run_anywhere_lora/ExampleUnitTest.kt b/examples/android/RunAnyWhereLora/app/src/test/java/com/runanywhere/run_anywhere_lora/ExampleUnitTest.kt
deleted file mode 100644
index e9e9889aa..000000000
--- a/examples/android/RunAnyWhereLora/app/src/test/java/com/runanywhere/run_anywhere_lora/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.runanywhere.run_anywhere_lora
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/examples/android/RunAnyWhereLora/build.gradle.kts b/examples/android/RunAnyWhereLora/build.gradle.kts
deleted file mode 100644
index 0519c0615..000000000
--- a/examples/android/RunAnyWhereLora/build.gradle.kts
+++ /dev/null
@@ -1,8 +0,0 @@
-plugins {
- alias(libs.plugins.android.application) apply false
- alias(libs.plugins.android.library) apply false
- alias(libs.plugins.kotlin.android) apply false
- alias(libs.plugins.kotlin.multiplatform) apply false
- alias(libs.plugins.kotlin.compose) apply false
- alias(libs.plugins.kotlin.serialization) apply false
-}
diff --git a/examples/android/RunAnyWhereLora/gradle/wrapper/gradle-wrapper.jar b/examples/android/RunAnyWhereLora/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index 8bdaf60c7..000000000
Binary files a/examples/android/RunAnyWhereLora/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/examples/android/RunAnyWhereLora/gradle/wrapper/gradle-wrapper.properties b/examples/android/RunAnyWhereLora/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index ed4c299ad..000000000
--- a/examples/android/RunAnyWhereLora/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-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/examples/android/RunAnyWhereLora/gradlew b/examples/android/RunAnyWhereLora/gradlew
deleted file mode 100755
index ef07e0162..000000000
--- a/examples/android/RunAnyWhereLora/gradlew
+++ /dev/null
@@ -1,251 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015 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/android/RunAnyWhereLora/gradlew.bat b/examples/android/RunAnyWhereLora/gradlew.bat
deleted file mode 100644
index db3a6ac20..000000000
--- a/examples/android/RunAnyWhereLora/gradlew.bat
+++ /dev/null
@@ -1,94 +0,0 @@
-@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/android/RunAnyWhereLora/settings.gradle.kts b/examples/android/RunAnyWhereLora/settings.gradle.kts
deleted file mode 100644
index 0e08f3d0d..000000000
--- a/examples/android/RunAnyWhereLora/settings.gradle.kts
+++ /dev/null
@@ -1,40 +0,0 @@
-pluginManagement {
- repositories {
- google {
- content {
- includeGroupByRegex("com\\.android.*")
- includeGroupByRegex("com\\.google.*")
- includeGroupByRegex("androidx.*")
- }
- }
- mavenCentral()
- gradlePluginPortal()
- }
-}
-
-dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
- repositories {
- mavenLocal()
- google()
- mavenCentral()
- maven { url = uri("https://jitpack.io") }
- }
- versionCatalogs {
- create("libs") {
- from(files("../../../gradle/libs.versions.toml"))
- }
- }
-}
-
-rootProject.name = "RunAnyWhere-Lora"
-include(":app")
-
-// SDK (local project dependency)
-include(":runanywhere-kotlin")
-project(":runanywhere-kotlin").projectDir = file("../../../sdk/runanywhere-kotlin")
-
-// LlamaCPP backend module
-include(":runanywhere-core-llamacpp")
-project(":runanywhere-core-llamacpp").projectDir =
- file("../../../sdk/runanywhere-kotlin/modules/runanywhere-core-llamacpp")
diff --git a/examples/android/RunAnywhereAI/app/build.gradle.kts b/examples/android/RunAnywhereAI/app/build.gradle.kts
index 29ab06bad..694be7fd4 100644
--- a/examples/android/RunAnywhereAI/app/build.gradle.kts
+++ b/examples/android/RunAnywhereAI/app/build.gradle.kts
@@ -49,10 +49,7 @@ android {
// }
// }
- ndk {
- // arm64-v8a for physical devices, x86_64 for emulators
- abiFilters += listOf("arm64-v8a", "x86_64")
- }
+ // Note: ndk.abiFilters removed — splits.abi handles ABI filtering
}
buildTypes {
@@ -108,21 +105,14 @@ android {
// 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")
- // }
- // }
+ splits {
+ abi {
+ isEnable = true
+ reset()
+ include("arm64-v8a", "x86_64")
+ isUniversalApk = false
+ }
+ }
// Packaging options
packaging {
diff --git a/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml b/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml
index bc032a7ff..da5593067 100644
--- a/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml
+++ b/examples/android/RunAnywhereAI/app/src/main/AndroidManifest.xml
@@ -40,6 +40,7 @@
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
index 2df64eda4..3d4f8f4ec 100644
--- 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
@@ -1,7 +1,7 @@
package com.runanywhere.runanywhereai
import android.os.Bundle
-import android.util.Log
+import timber.log.Timber
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -9,6 +9,7 @@ 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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
@@ -58,31 +59,26 @@ class MainActivity : ComponentActivity() {
val initState by app.initializationState.collectAsState()
val scope = rememberCoroutineScope()
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = MaterialTheme.colorScheme.background,
- ) {
- when (initState) {
- is SDKInitializationState.Loading -> {
- InitializationLoadingView()
- }
+ 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.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()
- }
+ is SDKInitializationState.Ready -> {
+ LaunchedEffect(Unit) { Timber.i("App is ready to use!") }
+ AppNavigation()
}
}
}
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
index 92a168455..ddc3b0c58 100644
--- 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
@@ -3,18 +3,10 @@ package com.runanywhere.runanywhereai
import android.app.Application
import android.os.Handler
import android.os.Looper
-import android.util.Log
+import com.runanywhere.runanywhereai.data.ModelList
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.ModelCompanionFile
-import com.runanywhere.sdk.public.extensions.Models.ModelCategory
-import com.runanywhere.sdk.public.extensions.Models.ModelFileDescriptor
-import com.runanywhere.sdk.public.extensions.registerModel
-import com.runanywhere.sdk.public.extensions.registerMultiFileModel
import com.runanywhere.sdk.storage.AndroidPlatformContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -26,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import timber.log.Timber
/**
* Represents the SDK initialization state.
@@ -70,7 +63,11 @@ class RunAnywhereApplication : Application() {
super.onCreate()
instance = this
- Log.i("RunAnywhereApp", "🏁 App launched, initializing SDK...")
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+
+ Timber.i("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
@@ -82,7 +79,7 @@ class RunAnywhereApplication : Application() {
delay(200)
initializeSDK()
} catch (e: Exception) {
- Log.e("RunAnywhereApp", "❌ Fatal error during SDK initialization: ${e.message}", e)
+ Timber.e(e, "❌ Fatal error during SDK initialization: ${e.message}")
// Don't crash the app - let it continue without SDK
}
}
@@ -97,14 +94,14 @@ class RunAnywhereApplication : Application() {
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", "=======================================================")
+ Timber.i("🎯 Starting SDK initialization...")
+ Timber.w("=======================================================")
+ Timber.w("🔍 BUILD INFO - CHECK THIS FOR ANALYTICS DEBUGGING:")
+ Timber.w(" BuildConfig.DEBUG = ${BuildConfig.DEBUG}")
+ Timber.w(" BuildConfig.DEBUG_MODE = ${BuildConfig.DEBUG_MODE}")
+ Timber.w(" BuildConfig.BUILD_TYPE = ${BuildConfig.BUILD_TYPE}")
+ Timber.w(" Package name = ${applicationContext.packageName}")
+ Timber.w("=======================================================")
val startTime = System.currentTimeMillis()
@@ -114,8 +111,8 @@ class RunAnywhereApplication : Application() {
val hasCustomConfig = customApiKey != null && customBaseURL != null
if (hasCustomConfig) {
- Log.i("RunAnywhereApp", "🔧 Found custom API configuration")
- Log.i("RunAnywhereApp", " Base URL: $customBaseURL")
+ Timber.i("🔧 Found custom API configuration")
+ Timber.i(" Base URL: $customBaseURL")
}
// Determine environment based on DEBUG_MODE (NOT BuildConfig.DEBUG!)
@@ -143,13 +140,13 @@ class RunAnywhereApplication : Application() {
baseURL = customBaseURL!!,
environment = environment,
)
- Log.i("RunAnywhereApp", "✅ SDK initialized with CUSTOM configuration (${environment.name.lowercase()})")
+ Timber.i("✅ 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)")
+ Timber.i("✅ 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
@@ -158,33 +155,30 @@ class RunAnywhereApplication : Application() {
// Detect placeholder credentials and abort production initialization
if (apiKey.startsWith("YOUR_") || baseURL.startsWith("YOUR_")) {
- Log.e(
- "RunAnywhereApp",
+ Timber.e(
"❌ 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)")
+ Timber.i("✅ 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")
+ Timber.i("✅ 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)")
+ RunAnywhere.completeServicesInitialization()
+ Timber.i("✅ 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}")
+ Timber.w("⚠️ SDK initialization failed (backend may be unavailable): ${e.message}")
initializationError = e
// Fall back to development mode
@@ -193,38 +187,36 @@ class RunAnywhereApplication : Application() {
RunAnywhere.initialize(
environment = SDKEnvironment.DEVELOPMENT,
)
- Log.i("RunAnywhereApp", "✅ SDK initialized in OFFLINE mode (local models only)")
+ Timber.i("✅ SDK initialized in OFFLINE mode (local models only)")
// Still try Phase 2 in offline mode
- kotlinx.coroutines.runBlocking {
- RunAnywhere.completeServicesInitialization()
- }
+ RunAnywhere.completeServicesInitialization()
} catch (fallbackError: Exception) {
- Log.e("RunAnywhereApp", "❌ Fallback initialization also failed: ${fallbackError.message}")
+ Timber.e("❌ Fallback initialization also failed: ${fallbackError.message}")
}
}
// Register modules and models
registerModulesAndModels()
- Log.i("RunAnywhereApp", "✅ SDK initialization complete")
+ Timber.i("✅ 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}")
+ Timber.i("✅ SDK setup completed in ${initTime}ms")
+ Timber.i("🎯 SDK Status: Active=${RunAnywhere.isInitialized}")
isSDKInitialized = RunAnywhere.isInitialized
// Update observable state for Compose UI
if (isSDKInitialized) {
_initializationState.value = SDKInitializationState.Ready
- Log.i("RunAnywhereApp", "🎉 App is ready to use!")
+ Timber.i("🎉 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)!")
+ Timber.i("🎉 App is ready to use (offline mode)!")
}
}
@@ -248,192 +240,7 @@ class RunAnywhereApplication : Application() {
}
}
- /**
- * Register modules with their associated models.
- * Each module explicitly owns its models - the framework is determined by the module.
- *
- * Backend registration MUST happen before model registration.
- */
- @Suppress("LongMethod")
private fun registerModulesAndModels() {
- Log.i("RunAnywhereApp", "📦 Registering backends and models...")
-
- // Register backends first
- // 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,
- )
- // LFM2-Tool models - For tool calling / function calling support
- RunAnywhere.registerModel(
- id = "lfm2-1.2b-tool-q4_k_m",
- name = "LiquidAI LFM2 1.2B Tool Q4_K_M",
- url = "https://huggingface.co/LiquidAI/LFM2-1.2B-Tool-GGUF/resolve/main/LFM2-1.2B-Tool-Q4_K_M.gguf",
- framework = InferenceFramework.LLAMA_CPP,
- memoryRequirement = 800_000_000,
- )
- RunAnywhere.registerModel(
- id = "lfm2-1.2b-tool-q8_0",
- name = "LiquidAI LFM2 1.2B Tool Q8_0",
- url = "https://huggingface.co/LiquidAI/LFM2-1.2B-Tool-GGUF/resolve/main/LFM2-1.2B-Tool-Q8_0.gguf",
- framework = InferenceFramework.LLAMA_CPP,
- memoryRequirement = 1_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")
-
-
- // Register ONNX Embedding models for RAG
- // all-MiniLM-L6-v2: registered as multi-file so model.onnx and vocab.txt
- // download into the same folder - C++ RAG pipeline looks for vocab.txt
- // next to model.onnx, so they must be co-located.
- // Mirrors iOS RunAnywhereAIApp.registerMultiFileModel() exactly.
- RunAnywhere.registerMultiFileModel(
- id = "all-minilm-l6-v2",
- name = "All MiniLM L6 v2 (Embedding)",
- primaryUrl = "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx", // .onnx keeps resolve (LFS binary)
- companionFiles = listOf(
- ModelCompanionFile(
- url = "https://huggingface.co/Xenova/all-MiniLM-L6-v2/raw/main/vocab.txt", // Changed to raw
- filename = "vocab.txt",
- ),
- ModelCompanionFile(
- url = "https://huggingface.co/Xenova/all-MiniLM-L6-v2/raw/main/tokenizer.json", // Added tokenizer and used raw
- filename = "tokenizer.json",
- ),
- ),
- framework = InferenceFramework.ONNX,
- modality = ModelCategory.EMBEDDING,
- memoryRequirement = 25_500_000,
- )
- Log.i("RunAnywhereApp", "✅ ONNX Embedding models registered")
-
- // Register VLM (Vision Language Model) models — matching iOS exactly
- // SmolVLM 500M - Ultra-lightweight VLM for mobile (~500MB total, archive)
- RunAnywhere.registerModel(
- id = "smolvlm-500m-instruct-q8_0",
- name = "SmolVLM 500M Instruct",
- url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-vlm-models-v1/smolvlm-500m-instruct-q8_0.tar.gz",
- framework = InferenceFramework.LLAMA_CPP,
- modality = ModelCategory.MULTIMODAL,
- memoryRequirement = 600_000_000,
- )
- // LFM2-VL 450M - LiquidAI's compact VLM, ideal for mobile (~600MB total)
- // Uses multi-file download: main model + mmproj from HuggingFace
- RunAnywhere.registerMultiFileModel(
- id = "lfm2-vl-450m-q8_0",
- name = "LFM2-VL 450M",
- files = listOf(
- ModelFileDescriptor(
- url = "https://huggingface.co/runanywhere/LFM2-VL-450M-GGUF/resolve/main/LFM2-VL-450M-Q8_0.gguf",
- filename = "LFM2-VL-450M-Q8_0.gguf",
- ),
- ModelFileDescriptor(
- url = "https://huggingface.co/runanywhere/LFM2-VL-450M-GGUF/resolve/main/mmproj-LFM2-VL-450M-Q8_0.gguf",
- filename = "mmproj-LFM2-VL-450M-Q8_0.gguf",
- ),
- ),
- framework = InferenceFramework.LLAMA_CPP,
- modality = ModelCategory.MULTIMODAL,
- memoryRequirement = 600_000_000,
- )
- // Qwen2-VL 2B - Capable VLM, requires powerful hardware (~1.6GB total)
- // Uses multi-file download: main model (986MB) + mmproj (710MB)
- RunAnywhere.registerMultiFileModel(
- id = "qwen2-vl-2b-instruct-q4_k_m",
- name = "Qwen2-VL 2B Instruct",
- files = listOf(
- ModelFileDescriptor(
- url = "https://huggingface.co/ggml-org/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-Q4_K_M.gguf",
- filename = "Qwen2-VL-2B-Instruct-Q4_K_M.gguf",
- ),
- ModelFileDescriptor(
- url = "https://huggingface.co/ggml-org/Qwen2-VL-2B-Instruct-GGUF/resolve/main/mmproj-Qwen2-VL-2B-Instruct-Q8_0.gguf",
- filename = "mmproj-Qwen2-VL-2B-Instruct-Q8_0.gguf",
- ),
- ),
- framework = InferenceFramework.LLAMA_CPP,
- modality = ModelCategory.MULTIMODAL,
- memoryRequirement = 1_800_000_000,
- )
- Log.i("RunAnywhereApp", "✅ VLM models registered")
-
-
- Log.i("RunAnywhereApp", "🎉 All modules and models registered")
+ ModelList.setupModels()
}
}
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
index a51c98fb7..238cd5b57 100644
--- 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
@@ -2,7 +2,7 @@ package com.runanywhere.runanywhereai.data
import android.annotation.SuppressLint
import android.content.Context
-import android.util.Log
+import timber.log.Timber
import com.runanywhere.runanywhereai.domain.models.ChatMessage
import com.runanywhere.runanywhereai.domain.models.Conversation
import com.runanywhere.runanywhereai.domain.models.MessageRole
@@ -182,7 +182,7 @@ class ConversationStore private constructor(context: Context) {
_currentConversation.value = loaded
return loaded
} catch (e: Exception) {
- Log.e("ConversationStore", "Failed to load conversation from disk", e)
+ Timber.e(e, "Failed to load conversation from disk")
}
}
@@ -228,7 +228,7 @@ class ConversationStore private constructor(context: Context) {
val jsonString = file.readText()
json.decodeFromString(jsonString)
} catch (e: Exception) {
- Log.e("ConversationStore", "Failed to load conversation: ${file.name}", e)
+ Timber.e(e, "Failed to load conversation: ${file.name}")
null
}
}
@@ -238,7 +238,7 @@ class ConversationStore private constructor(context: Context) {
// Don't automatically set current conversation - let ChatViewModel create a new one
} catch (e: Exception) {
- Log.e("ConversationStore", "Failed to load conversations", e)
+ Timber.e(e, "Failed to load conversations")
}
}
@@ -251,7 +251,7 @@ class ConversationStore private constructor(context: Context) {
val jsonString = json.encodeToString(conversation)
file.writeText(jsonString)
} catch (e: Exception) {
- Log.e("ConversationStore", "Failed to save conversation", e)
+ Timber.e(e, "Failed to save conversation")
}
}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ModelList.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ModelList.kt
new file mode 100644
index 000000000..baa373bd1
--- /dev/null
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/ModelList.kt
@@ -0,0 +1,207 @@
+package com.runanywhere.runanywhereai.data
+
+import timber.log.Timber
+import com.runanywhere.runanywhereai.data.models.AppModel
+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.extensions.LoraAdapterCatalogEntry
+import com.runanywhere.sdk.public.extensions.ModelCompanionFile
+import com.runanywhere.sdk.public.extensions.Models.ModelCategory
+import com.runanywhere.sdk.public.extensions.Models.ModelFileDescriptor
+import com.runanywhere.sdk.public.extensions.registerLoraAdapter
+import com.runanywhere.sdk.public.extensions.registerModel
+import com.runanywhere.sdk.public.extensions.registerMultiFileModel
+
+object ModelList {
+ // LLM Models
+ private val llmModels = listOf(
+ AppModel(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, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 500_000_000),
+ AppModel(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, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 4_000_000_000),
+ AppModel(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, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 4_000_000_000),
+ AppModel(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, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 600_000_000),
+ AppModel(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, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 250_000_000, supportsLoraAdapters = true),
+ AppModel(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, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 400_000_000, supportsLoraAdapters = true),
+ AppModel(id = "lfm2-1.2b-tool-q4_k_m", name = "LiquidAI LFM2 1.2B Tool Q4_K_M",
+ url = "https://huggingface.co/LiquidAI/LFM2-1.2B-Tool-GGUF/resolve/main/LFM2-1.2B-Tool-Q4_K_M.gguf",
+ framework = InferenceFramework.LLAMA_CPP, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 800_000_000),
+ AppModel(id = "lfm2-1.2b-tool-q8_0", name = "LiquidAI LFM2 1.2B Tool Q8_0",
+ url = "https://huggingface.co/LiquidAI/LFM2-1.2B-Tool-GGUF/resolve/main/LFM2-1.2B-Tool-Q8_0.gguf",
+ framework = InferenceFramework.LLAMA_CPP, category = ModelCategory.LANGUAGE,
+ memoryRequirement = 1_400_000_000),
+ )
+
+ // STT / TTS
+ private val sttModels = listOf(
+ AppModel(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, category = ModelCategory.SPEECH_RECOGNITION,
+ memoryRequirement = 75_000_000),
+ )
+ private val ttsModels = listOf(
+ AppModel(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, category = ModelCategory.SPEECH_SYNTHESIS,
+ memoryRequirement = 65_000_000),
+ AppModel(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, category = ModelCategory.SPEECH_SYNTHESIS,
+ memoryRequirement = 65_000_000),
+ )
+
+ // Embedding
+ private val embeddingModels = listOf(
+ AppModel(id = "all-minilm-l6-v2", name = "All MiniLM L6 v2 (Embedding)",
+ url = "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx",
+ framework = InferenceFramework.ONNX, category = ModelCategory.EMBEDDING,
+ memoryRequirement = 25_500_000,
+ companionFiles = listOf(
+ ModelCompanionFile(url = "https://huggingface.co/Xenova/all-MiniLM-L6-v2/raw/main/vocab.txt", filename = "vocab.txt"),
+ ModelCompanionFile(url = "https://huggingface.co/Xenova/all-MiniLM-L6-v2/raw/main/tokenizer.json", filename = "tokenizer.json"),
+ )),
+ )
+
+ // LoRA Adapters (from Void2377/Qwen on HuggingFace — real standalone LoRA GGUF files)
+ private val loraAdapters = listOf(
+ LoraAdapterCatalogEntry(
+ id = "chat-assistant-lora",
+ name = "Chat Assistant",
+ description = "Enhances conversational chat ability",
+ downloadUrl = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/chat_assistant-lora-Q8_0.gguf",
+ filename = "chat_assistant-lora-Q8_0.gguf",
+ compatibleModelIds = listOf("lfm2-350m-q4_k_m", "lfm2-350m-q8_0"),
+ fileSize = 690_176,
+ defaultScale = 1.0f,
+ ),
+ LoraAdapterCatalogEntry(
+ id = "summarizer-lora",
+ name = "Summarizer",
+ description = "Specialized for text summarization tasks",
+ downloadUrl = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/summarizer-lora-Q8_0.gguf",
+ filename = "summarizer-lora-Q8_0.gguf",
+ compatibleModelIds = listOf("lfm2-350m-q4_k_m", "lfm2-350m-q8_0"),
+ fileSize = 690_176,
+ defaultScale = 1.0f,
+ ),
+ LoraAdapterCatalogEntry(
+ id = "translator-lora",
+ name = "Translator",
+ description = "Improves translation between languages",
+ downloadUrl = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/translator-lora-Q8_0.gguf",
+ filename = "translator-lora-Q8_0.gguf",
+ compatibleModelIds = listOf("lfm2-350m-q4_k_m", "lfm2-350m-q8_0"),
+ fileSize = 690_176,
+ defaultScale = 1.0f,
+ ),
+ LoraAdapterCatalogEntry(
+ id = "sentiment-lora",
+ name = "Sentiment Analysis",
+ description = "Fine-tuned for sentiment analysis tasks",
+ downloadUrl = "https://huggingface.co/Void2377/Qwen/resolve/main/lora/sentiment-lora-Q8_0.gguf",
+ filename = "sentiment-lora-Q8_0.gguf",
+ compatibleModelIds = listOf("lfm2-350m-q4_k_m", "lfm2-350m-q8_0"),
+ fileSize = 690_176,
+ defaultScale = 1.0f,
+ ),
+ )
+
+ // VLM
+ private val vlmModels = listOf(
+ AppModel(id = "smolvlm-500m-instruct-q8_0", name = "SmolVLM 500M Instruct",
+ url = "https://github.com/RunanywhereAI/sherpa-onnx/releases/download/runanywhere-vlm-models-v1/smolvlm-500m-instruct-q8_0.tar.gz",
+ framework = InferenceFramework.LLAMA_CPP, category = ModelCategory.MULTIMODAL,
+ memoryRequirement = 600_000_000),
+ AppModel(id = "lfm2-vl-450m-q8_0", name = "LFM2-VL 450M", url = "",
+ framework = InferenceFramework.LLAMA_CPP, category = ModelCategory.MULTIMODAL,
+ memoryRequirement = 600_000_000,
+ files = listOf(
+ ModelFileDescriptor(url = "https://huggingface.co/runanywhere/LFM2-VL-450M-GGUF/resolve/main/LFM2-VL-450M-Q8_0.gguf", filename = "LFM2-VL-450M-Q8_0.gguf"),
+ ModelFileDescriptor(url = "https://huggingface.co/runanywhere/LFM2-VL-450M-GGUF/resolve/main/mmproj-LFM2-VL-450M-Q8_0.gguf", filename = "mmproj-LFM2-VL-450M-Q8_0.gguf"),
+ )),
+ AppModel(id = "qwen2-vl-2b-instruct-q4_k_m", name = "Qwen2-VL 2B Instruct", url = "",
+ framework = InferenceFramework.LLAMA_CPP, category = ModelCategory.MULTIMODAL,
+ memoryRequirement = 1_800_000_000,
+ files = listOf(
+ ModelFileDescriptor(url = "https://huggingface.co/ggml-org/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-Q4_K_M.gguf", filename = "Qwen2-VL-2B-Instruct-Q4_K_M.gguf"),
+ ModelFileDescriptor(url = "https://huggingface.co/ggml-org/Qwen2-VL-2B-Instruct-GGUF/resolve/main/mmproj-Qwen2-VL-2B-Instruct-Q8_0.gguf", filename = "mmproj-Qwen2-VL-2B-Instruct-Q8_0.gguf"),
+ )),
+ )
+
+ fun setupModels() {
+ Timber.i("Registering backends and models...")
+ try {
+ LlamaCPP.register(priority = 100)
+ ONNX.register(priority = 100)
+ Timber.i("Backends registered")
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to register backends")
+ return
+ }
+
+ val allModels = listOf(
+ "LLM/STT/TTS" to (llmModels + sttModels + ttsModels),
+ "Embedding" to embeddingModels,
+ "VLM" to vlmModels,
+ )
+ for ((label, models) in allModels) {
+ for (model in models) {
+ try {
+ if (model.files.isNotEmpty()) {
+ RunAnywhere.registerMultiFileModel(
+ id = model.id, name = model.name, files = model.files,
+ framework = model.framework, modality = model.category,
+ memoryRequirement = model.memoryRequirement,
+ )
+ } else if (model.companionFiles.isNotEmpty()) {
+ RunAnywhere.registerMultiFileModel(
+ id = model.id, name = model.name, primaryUrl = model.url,
+ companionFiles = model.companionFiles,
+ framework = model.framework, modality = model.category,
+ memoryRequirement = model.memoryRequirement,
+ )
+ } else {
+ RunAnywhere.registerModel(
+ id = model.id, name = model.name, url = model.url,
+ framework = model.framework, modality = model.category,
+ memoryRequirement = model.memoryRequirement,
+ supportsLora = model.supportsLoraAdapters,
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to register model: ${model.id}")
+ }
+ }
+ Timber.i("$label models registered (${models.size})")
+ }
+
+ for (adapter in loraAdapters) {
+ try {
+ RunAnywhere.registerLoraAdapter(adapter)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to register LoRA adapter: ${adapter.id}")
+ }
+ }
+ Timber.i("LoRA adapters registered (${loraAdapters.size})")
+ Timber.i("All models registered")
+ }
+}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/models/AppModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/models/AppModel.kt
new file mode 100644
index 000000000..c3e3ed29c
--- /dev/null
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/data/models/AppModel.kt
@@ -0,0 +1,18 @@
+package com.runanywhere.runanywhereai.data.models
+
+import com.runanywhere.sdk.core.types.InferenceFramework
+import com.runanywhere.sdk.public.extensions.ModelCompanionFile
+import com.runanywhere.sdk.public.extensions.Models.ModelCategory
+import com.runanywhere.sdk.public.extensions.Models.ModelFileDescriptor
+
+data class AppModel(
+ val id: String,
+ val name: String,
+ val url: String,
+ val framework: InferenceFramework,
+ val category: ModelCategory = ModelCategory.LANGUAGE,
+ val memoryRequirement: Long = 0,
+ val supportsLoraAdapters: Boolean = false,
+ val companionFiles: List = emptyList(),
+ val files: List = emptyList(),
+)
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
index ca7a4df0b..1c56c6483 100644
--- 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
@@ -6,7 +6,7 @@ import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
-import android.util.Log
+import timber.log.Timber
import androidx.core.app.ActivityCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
@@ -21,8 +21,6 @@ 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
*
@@ -74,7 +72,7 @@ class AudioCaptureService(
fun startCapture(): Flow =
callbackFlow {
if (!hasRecordPermission()) {
- Log.e(TAG, "No RECORD_AUDIO permission")
+ Timber.e("No RECORD_AUDIO permission")
close(SecurityException("RECORD_AUDIO permission not granted"))
return@callbackFlow
}
@@ -93,14 +91,14 @@ class AudioCaptureService(
)
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
- Log.e(TAG, "AudioRecord failed to initialize")
+ Timber.e("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)")
+ Timber.i("Audio capture started (${SAMPLE_RATE}Hz, chunk size: $chunkSize)")
// Launch a coroutine on IO dispatcher to read audio
val readJob =
@@ -120,7 +118,7 @@ class AudioCaptureService(
// trySend is safe to call from any context in callbackFlow
trySend(chunk)
} else if (bytesRead < 0) {
- Log.w(TAG, "AudioRecord read error: $bytesRead")
+ Timber.w("AudioRecord read error: $bytesRead")
break
}
}
@@ -128,12 +126,12 @@ class AudioCaptureService(
// Wait for cancellation
awaitClose {
- Log.d(TAG, "Flow closing, stopping audio capture")
+ Timber.d("Flow closing, stopping audio capture")
readJob.cancel()
stopCaptureInternal()
}
} catch (e: Exception) {
- Log.e(TAG, "Error in audio capture: ${e.message}")
+ Timber.e("Error in audio capture: ${e.message}")
stopCaptureInternal()
close(e)
}
@@ -154,9 +152,9 @@ class AudioCaptureService(
audioRecord = null
_isRecording.value = false
_audioLevel.value = 0f
- Log.d(TAG, "Audio capture stopped")
+ Timber.d("Audio capture stopped")
} catch (e: Exception) {
- Log.w(TAG, "Error stopping audio capture: ${e.message}")
+ Timber.w("Error stopping audio capture: ${e.message}")
}
}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/models/BenchmarkTypes.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/models/BenchmarkTypes.kt
index fac3416b6..99bd7607b 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/models/BenchmarkTypes.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/models/BenchmarkTypes.kt
@@ -4,7 +4,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Visibility
-import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.ui.graphics.vector.ImageVector
import com.runanywhere.sdk.public.extensions.Models.ModelCategory
import com.runanywhere.sdk.public.extensions.Models.ModelInfo
@@ -34,7 +34,7 @@ enum class BenchmarkCategory(val value: String) {
get() = when (this) {
LLM -> Icons.Filled.ChatBubble
STT -> Icons.Filled.GraphicEq
- TTS -> Icons.Filled.VolumeUp
+ TTS -> Icons.AutoMirrored.Filled.VolumeUp
VLM -> Icons.Filled.Visibility
}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDashboardScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDashboardScreen.kt
index 8b1e01b5c..61f180b41 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDashboardScreen.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDashboardScreen.kt
@@ -22,7 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Warning
@@ -31,16 +31,13 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -58,6 +55,7 @@ import com.runanywhere.runanywhereai.presentation.benchmarks.models.BenchmarkRun
import com.runanywhere.runanywhereai.presentation.benchmarks.models.BenchmarkRunStatus
import com.runanywhere.runanywhereai.presentation.benchmarks.utilities.SyntheticInputGenerator
import com.runanywhere.runanywhereai.presentation.benchmarks.viewmodel.BenchmarkViewModel
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.ui.theme.AppSpacing
import com.runanywhere.sdk.models.DeviceInfo
@@ -69,98 +67,95 @@ import java.time.format.DateTimeFormatter
* Main benchmarking screen: device info, category filters, run controls, and history.
* Matches iOS BenchmarkDashboardView exactly.
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BenchmarkDashboardScreen(
onNavigateToDetail: (String) -> Unit,
+ onBack: () -> Unit = {},
benchmarkViewModel: BenchmarkViewModel = viewModel(),
) {
val uiState by benchmarkViewModel.uiState.collectAsStateWithLifecycle()
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Benchmarks") },
- actions = {
- if (uiState.pastRuns.isNotEmpty()) {
- IconButton(onClick = { benchmarkViewModel.showClearConfirmation() }) {
- Icon(
- Icons.Filled.Delete,
- contentDescription = "Clear All",
- tint = AppColors.statusRed,
- )
- }
- }
- },
- )
+ ConfigureTopBar(
+ title = "Benchmarks",
+ showBack = true,
+ onBack = onBack,
+ actions = {
+ if (uiState.pastRuns.isNotEmpty()) {
+ IconButton(onClick = { benchmarkViewModel.showClearConfirmation() }) {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = "Clear All",
+ tint = AppColors.statusRed,
+ )
+ }
+ }
},
- ) { paddingValues ->
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = AppSpacing.large),
- verticalArrangement = Arrangement.spacedBy(AppSpacing.large),
- ) {
- // Device Info
- item { DeviceInfoSection() }
+ )
- // Benchmark Suite Info
- item { BenchmarkSuiteInfoSection() }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = AppSpacing.large),
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.large),
+ ) {
+ // Device Info
+ item { DeviceInfoSection() }
+
+ // Benchmark Suite Info
+ item { BenchmarkSuiteInfoSection() }
+
+ // Category Selection
+ item { CategorySelectionSection(uiState.selectedCategories, benchmarkViewModel) }
+
+ // Scenario descriptions
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(AppSpacing.small)) {
+ BenchmarkCategory.entries
+ .filter { it in uiState.selectedCategories }
+ .forEach { category ->
+ CategoryScenarioRow(category)
+ }
+ }
+ }
- // Category Selection
- item { CategorySelectionSection(uiState.selectedCategories, benchmarkViewModel) }
+ // Run Controls
+ item {
+ RunControlsSection(
+ selectedCategories = uiState.selectedCategories,
+ isRunning = uiState.isRunning,
+ onRunAll = {
+ benchmarkViewModel.selectAllCategories()
+ benchmarkViewModel.runBenchmarks()
+ },
+ onRunSelected = { benchmarkViewModel.runBenchmarks() },
+ )
+ }
- // Scenario descriptions
- item {
- Column(verticalArrangement = Arrangement.spacedBy(AppSpacing.small)) {
- BenchmarkCategory.entries
- .filter { it in uiState.selectedCategories }
- .forEach { category ->
- CategoryScenarioRow(category)
- }
- }
- }
+ // Skipped categories warning
+ uiState.skippedCategoriesMessage?.let { msg ->
+ item { SkippedWarning(msg) }
+ }
- // Run Controls
+ // Past Runs or Empty State
+ if (uiState.pastRuns.isNotEmpty()) {
item {
- RunControlsSection(
- selectedCategories = uiState.selectedCategories,
- isRunning = uiState.isRunning,
- onRunAll = {
- benchmarkViewModel.selectAllCategories()
- benchmarkViewModel.runBenchmarks()
- },
- onRunSelected = { benchmarkViewModel.runBenchmarks() },
+ Text(
+ "History",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = AppSpacing.small),
)
}
-
- // Skipped categories warning
- uiState.skippedCategoriesMessage?.let { msg ->
- item { SkippedWarning(msg) }
+ items(uiState.pastRuns, key = { it.id }) { run ->
+ RunRow(run = run, onClick = { onNavigateToDetail(run.id) })
}
-
- // Past Runs or Empty State
- if (uiState.pastRuns.isNotEmpty()) {
- item {
- Text(
- "History",
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = AppSpacing.small),
- )
- }
- items(uiState.pastRuns, key = { it.id }) { run ->
- RunRow(run = run, onClick = { onNavigateToDetail(run.id) })
- }
- } else {
- item { EmptyState() }
- }
-
- item { Spacer(modifier = Modifier.height(AppSpacing.xxLarge)) }
+ } else {
+ item { EmptyState() }
}
- }
+ item { Spacer(modifier = Modifier.height(AppSpacing.xxLarge)) }
+ }
+
// Progress Dialog
if (uiState.isRunning) {
BenchmarkProgressDialog(
@@ -172,7 +167,7 @@ fun BenchmarkDashboardScreen(
onCancel = { benchmarkViewModel.cancel() },
)
}
-
+
// Clear Confirmation Dialog
if (uiState.showClearConfirmation) {
AlertDialog(
@@ -190,7 +185,7 @@ fun BenchmarkDashboardScreen(
},
)
}
-
+
// Error Dialog
uiState.errorMessage?.let { error ->
AlertDialog(
@@ -200,8 +195,8 @@ fun BenchmarkDashboardScreen(
confirmButton = {
TextButton(onClick = { benchmarkViewModel.dismissError() }) { Text("OK") }
},
- )
- }
+ )
+ }
}
// -- Device Info Section --
@@ -462,7 +457,7 @@ private fun RunRow(run: BenchmarkRun, onClick: () -> Unit) {
}
Spacer(modifier = Modifier.width(AppSpacing.small))
Icon(
- Icons.Filled.KeyboardArrowRight,
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDetailScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDetailScreen.kt
index faf935fc1..fe75f9fec 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDetailScreen.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/benchmarks/views/BenchmarkDetailScreen.kt
@@ -31,13 +31,10 @@ import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -57,6 +54,7 @@ import com.runanywhere.runanywhereai.presentation.benchmarks.models.BenchmarkRun
import com.runanywhere.runanywhereai.presentation.benchmarks.models.BenchmarkRunStatus
import com.runanywhere.runanywhereai.presentation.benchmarks.utilities.BenchmarkExportFormat
import com.runanywhere.runanywhereai.presentation.benchmarks.viewmodel.BenchmarkViewModel
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.ui.theme.AppSpacing
import java.time.Instant
@@ -70,36 +68,30 @@ private val dateFormat: DateTimeFormatter =
* Shows details of a single benchmark run with export actions.
* Matches iOS BenchmarkDetailView exactly.
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BenchmarkDetailScreen(
runId: String,
+ onBack: () -> Unit = {},
benchmarkViewModel: BenchmarkViewModel = viewModel(),
) {
val uiState by benchmarkViewModel.uiState.collectAsStateWithLifecycle()
val run = uiState.pastRuns.find { it.id == runId }
val context = LocalContext.current
- Scaffold(
- topBar = { TopAppBar(title = { Text("Benchmark Details") }) },
- ) { paddingValues ->
- if (run == null) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues),
- contentAlignment = Alignment.Center,
- ) {
- Text("Run not found", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- return@Scaffold
- }
+ ConfigureTopBar(title = "Benchmark Details", showBack = true, onBack = onBack)
+ if (run == null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("Run not found", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ } else {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
- .padding(paddingValues)
.padding(horizontal = AppSpacing.large),
verticalArrangement = Arrangement.spacedBy(AppSpacing.large),
) {
@@ -138,7 +130,7 @@ fun BenchmarkDetailScreen(
}
// Copied toast overlay
- AnimatedVisibility(
+ androidx.compose.animation.AnimatedVisibility(
visible = uiState.copiedToastMessage != null,
modifier = Modifier.align(Alignment.BottomCenter),
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
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
index d5ac8f879..c8a1dc4bd 100644
--- 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
@@ -22,6 +22,7 @@ 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.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -55,6 +56,12 @@ 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.presentation.components.ConfigureCustomTopBar
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
+import com.runanywhere.runanywhereai.presentation.lora.LoraAdapterPickerSheet
+import com.runanywhere.runanywhereai.presentation.lora.LoraViewModel
+import com.runanywhere.sdk.public.RunAnywhere
+import com.runanywhere.sdk.public.extensions.currentLLMModelId
import com.runanywhere.runanywhereai.ui.theme.AppColors
import android.app.Application
import com.runanywhere.runanywhereai.ui.theme.AppTypography
@@ -66,7 +73,10 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun ChatScreen(viewModel: ChatViewModel = viewModel()) {
+fun ChatScreen(
+ viewModel: ChatViewModel = viewModel(),
+ loraViewModel: LoraViewModel = viewModel(),
+) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -74,6 +84,7 @@ fun ChatScreen(viewModel: ChatViewModel = viewModel()) {
var showingConversationList by remember { mutableStateOf(false) }
var showingModelSelection by remember { mutableStateOf(false) }
var showingChatDetails by remember { mutableStateOf(false) }
+ var showingLoraAdapterPicker by remember { mutableStateOf(false) }
var showDebugAlert by remember { mutableStateOf(false) }
var debugMessage by remember { mutableStateOf("") }
@@ -88,30 +99,39 @@ fun ChatScreen(viewModel: ChatViewModel = viewModel()) {
}
}
- Scaffold(
- topBar = {
- if (uiState.isModelLoaded) {
- ChatTopBar(
- hasMessages = uiState.messages.isNotEmpty(),
- modelName = uiState.loadedModelName,
- supportsStreaming = uiState.useStreaming,
- onHistoryClick = {
- viewModel.ensureCurrentConversationInHistory()
- showingConversationList = true
- },
- onInfoClick = { showingChatDetails = true },
- onModelClick = { showingModelSelection = true },
- )
- }
- },
- ) { padding ->
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
- ) {
- Column(modifier = Modifier.fillMaxSize()) {
+ if (uiState.isModelLoaded) {
+ ConfigureCustomTopBar {
+ ChatTopBar(
+ hasMessages = uiState.messages.isNotEmpty(),
+ modelName = uiState.loadedModelName,
+ supportsStreaming = uiState.useStreaming,
+ supportsLora = uiState.currentModelSupportsLora,
+ hasActiveLoraAdapter = uiState.hasActiveLoraAdapter,
+ onHistoryClick = {
+ viewModel.ensureCurrentConversationInHistory()
+ showingConversationList = true
+ },
+ onInfoClick = { showingChatDetails = true },
+ onModelClick = { showingModelSelection = true },
+ onLoraClick = {
+ RunAnywhere.currentLLMModelId?.let { modelId ->
+ loraViewModel.refreshForModel(modelId)
+ }
+ showingLoraAdapterPicker = true
+ },
+ )
+ }
+ } else {
+ ConfigureTopBar(title = "Chat")
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ Column(modifier = Modifier.fillMaxSize().imePadding()) {
if (uiState.isModelLoaded) {
if (uiState.messages.isEmpty() && !uiState.isGenerating) {
EmptyStateView(modifier = Modifier.weight(1f))
@@ -189,7 +209,6 @@ fun ChatScreen(viewModel: ChatViewModel = viewModel()) {
onDismiss = { showModelLoadedToast = false },
modifier = Modifier.align(Alignment.TopCenter),
)
- }
}
if (showingModelSelection) {
@@ -206,6 +225,16 @@ fun ChatScreen(viewModel: ChatViewModel = viewModel()) {
)
}
+ if (showingLoraAdapterPicker) {
+ LoraAdapterPickerSheet(
+ loraViewModel = loraViewModel,
+ onDismiss = {
+ showingLoraAdapterPicker = false
+ viewModel.refreshLoraState()
+ },
+ )
+ }
+
if (showingConversationList) {
val context = LocalContext.current
val conversationStore = remember { ConversationStore.getInstance(context) }
@@ -282,97 +311,31 @@ fun ChatTopBar(
hasMessages: Boolean,
modelName: String?,
supportsStreaming: Boolean,
+ supportsLora: Boolean = false,
+ hasActiveLoraAdapter: Boolean = false,
onHistoryClick: () -> Unit,
onInfoClick: () -> Unit,
onModelClick: () -> Unit,
+ onLoraClick: () -> Unit = {},
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
- Text(
- text = "Chat",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- },
- actions = {
- Surface(
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(4.dp),
- ) {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier
- .size(34.dp)
- .clip(CircleShape)
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- onClick = onHistoryClick,
- ),
- ) {
- Icon(
- imageVector = Icons.Default.History,
- contentDescription = "History",
- modifier = Modifier.size(20.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- }
-
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier
- .size(34.dp)
- .clip(CircleShape)
- .then(
- if (hasMessages) {
- Modifier
- .background(AppColors.primaryAccent, CircleShape)
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- onClick = onInfoClick,
- )
- } else {
- Modifier
- }
- ),
- ) {
- Icon(
- imageVector = Icons.Default.Info,
- contentDescription = "Info",
- modifier = Modifier.size(20.dp),
- tint = if (hasMessages) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
-
- Spacer(modifier = Modifier.width(8.dp))
-
+ // Model chip as the title — clickable to switch model
Surface(
onClick = onModelClick,
- shape = RoundedCornerShape(50),
+ shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(
- start = 6.dp,
- end = 12.dp,
- top = 6.dp,
- bottom = 6.dp,
- ),
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
) {
if (modelName != null) {
Box(
modifier = Modifier
- .size(30.dp)
+ .size(26.dp)
.clip(RoundedCornerShape(6.dp)),
) {
Image(
@@ -382,34 +345,54 @@ fun ChatTopBar(
contentScale = ContentScale.Fit,
)
}
-
Spacer(modifier = Modifier.width(8.dp))
-
- Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
- Text(
- text = shortModelName(modelName, maxLength = 12),
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(3.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.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 = shortModelName(modelName, maxLength = 14),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ // LoRA active badge — inline next to model name
+ if (hasActiveLoraAdapter) {
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = AppColors.primaryPurple,
+ ) {
+ Text(
+ text = "LoRA",
+ style = MaterialTheme.typography.labelSmall.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 9.sp,
+ letterSpacing = 0.3.sp,
+ ),
+ color = Color.White,
+ modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp),
+ )
+ }
+ }
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(6.dp)
+ .clip(CircleShape)
+ .background(
+ if (supportsStreaming) AppColors.primaryGreen else AppColors.primaryOrange,
+ ),
)
Text(
text = if (supportsStreaming) "Streaming" else "Batch",
- style = MaterialTheme.typography.labelSmall.copy(
- fontSize = 10.sp,
- fontWeight = FontWeight.Medium,
- ),
- color = if (supportsStreaming) AppColors.primaryGreen else AppColors.primaryOrange,
+ style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -423,12 +406,53 @@ fun ChatTopBar(
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Select Model",
- style = MaterialTheme.typography.labelMedium,
+ style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
)
}
}
}
+ },
+ actions = {
+ // LoRA button — ghost when no adapter, hidden when not supported
+ if (supportsLora && !hasActiveLoraAdapter) {
+ TextButton(
+ onClick = onLoraClick,
+ contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
+ ) {
+ Text(
+ text = "+ LoRA",
+ style = MaterialTheme.typography.labelSmall.copy(
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.3.sp,
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ // History
+ IconButton(onClick = onHistoryClick) {
+ Icon(
+ imageVector = Icons.Default.History,
+ contentDescription = "History",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ // Info — highlighted when there are messages
+ IconButton(
+ onClick = onInfoClick,
+ enabled = hasMessages,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = "Info",
+ modifier = Modifier.size(20.dp),
+ tint = if (hasMessages) AppColors.primaryAccent else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
+ )
+ }
Spacer(modifier = Modifier.width(4.dp))
},
@@ -446,13 +470,6 @@ fun MessageBubbleView(
modifier: Modifier = Modifier,
) {
var showToolCallSheet by remember { mutableStateOf(false) }
-
- val alignment =
- if (message.role == MessageRole.USER) {
- Arrangement.End
- } else {
- Arrangement.Start
- }
// context menu state
var showDialog by remember { mutableStateOf(false) }
@@ -461,34 +478,96 @@ fun MessageBubbleView(
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = alignment,
- ) {
- if (message.role == MessageRole.USER) {
- Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing))
- }
+ val isUserMessage = message.role == MessageRole.USER
- Column(
- modifier = Modifier.widthIn(max = Dimensions.messageBubbleMaxWidth),
- horizontalAlignment =
- if (message.role == MessageRole.USER) {
- Alignment.End
- } else {
- Alignment.Start
- },
+ // Context menu dialog (shared by both user and assistant)
+ if (showDialog) {
+ BasicAlertDialog(
+ onDismissRequest = { showDialog = false },
+ modifier = Modifier
+ .clip(RoundedCornerShape(Dimensions.cornerRadiusModal))
+ .background(MaterialTheme.colorScheme.surface)
+ .widthIn(max = Dimensions.contextMenuMaxWidth)
) {
- // 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))
+ 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
+ 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 }
+ )
+ }
- // Tool call indicator (for assistant messages with tool calls) - matching iOS
- if (message.role == MessageRole.ASSISTANT && message.toolCallInfo != null) {
+ if (isUserMessage) {
+ // ── User message: right-aligned, simple solid rounded background ──
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(Dimensions.messageMaxWidthFraction)
+ .wrapContentWidth(Alignment.End),
+ ) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(Dimensions.userBubbleCornerRadius))
+ .background(AppColors.userBubbleColor())
+ .combinedClickable(
+ onClick = { /* No-op */ },
+ onLongClick = { showDialog = true },
+ ),
+ ) {
+ Text(
+ text = message.content,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(
+ horizontal = Dimensions.messageBubblePaddingHorizontal,
+ vertical = Dimensions.messageBubblePaddingVertical,
+ ),
+ )
+ }
+ }
+ }
+ } else {
+ // ── Assistant message: full-width, no bubble, model icon at top-left ──
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ // Tool call indicator
+ if (message.toolCallInfo != null) {
com.runanywhere.runanywhereai.presentation.chat.components.ToolCallIndicator(
toolCallInfo = message.toolCallInfo,
onTap = { showToolCallSheet = true },
@@ -496,186 +575,64 @@ fun MessageBubbleView(
Spacer(modifier = Modifier.height(Dimensions.small))
}
+ // Thinking toggle
message.thinkingContent?.let { thinking ->
- ThinkingToggle(
- thinkingContent = thinking,
- )
+ ThinkingToggle(thinkingContent = thinking)
Spacer(modifier = Modifier.height(Dimensions.small))
}
- if (message.role == MessageRole.ASSISTANT &&
- message.content.isEmpty() &&
- message.thinkingContent != null &&
- isGenerating
- ) {
+ // Thinking progress (empty content but thinking exists)
+ if (message.content.isEmpty() && message.thinkingContent != null && isGenerating) {
ThinkingProgressIndicator()
}
+ // Main content: icon + markdown
if (message.content.isNotEmpty()) {
- 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
- 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 },
- ),
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = { /* No-op */ },
+ onLongClick = { showDialog = true },
+ ),
+ verticalAlignment = Alignment.Top,
) {
- if (isUserMessage) {
- Text(
- text = message.content,
- style = MaterialTheme.typography.bodyLarge,
- color = AppColors.textWhite,
- modifier =
- Modifier.padding(
- horizontal = Dimensions.messageBubblePaddingHorizontal,
- vertical = Dimensions.messageBubblePaddingVertical,
- ),
+ // Small model icon
+ if (message.modelInfo != null) {
+ Image(
+ painter = painterResource(id = getModelLogoResIdForName(message.modelInfo.modelName)),
+ contentDescription = null,
+ modifier = Modifier
+ .size(Dimensions.assistantIconSize)
+ .clip(RoundedCornerShape(4.dp)),
+ contentScale = ContentScale.Fit,
)
- } else {
- Box {
- Column(
- modifier = Modifier.padding(
- horizontal = Dimensions.messageBubblePaddingHorizontal,
- vertical = Dimensions.messageBubblePaddingVertical,
- ),
- ) {
- MarkdownText(
- markdown = message.content,
- style = MaterialTheme.typography.bodyLarge,
- color = AppColors.assistantBubbleTextColor(),
- )
-
- if (message.modelInfo != null) {
- Spacer(modifier = Modifier.height(Dimensions.large))
- }
- }
-
- if (message.modelInfo != null) {
- Row(
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(
- end = Dimensions.mediumLarge,
- bottom = Dimensions.small,
- ),
- horizontalArrangement = Arrangement.spacedBy(3.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = Icons.Default.ViewInAr,
- contentDescription = null,
- modifier = Modifier.size(8.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- )
- Text(
- text = message.modelInfo.modelName,
- style = MaterialTheme.typography.labelSmall.copy(
- fontSize = 9.sp,
- fontWeight = FontWeight.Medium,
- ),
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
- )
- }
- }
- }
+ Spacer(modifier = Modifier.width(Dimensions.assistantIconSpacing))
}
+
+ // Full-width markdown text, no background
+ MarkdownText(
+ markdown = message.content,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
}
}
- if (message.role == MessageRole.ASSISTANT &&
- message.content.isNotEmpty() &&
- !isGenerating
- ) {
+ // Analytics footer (left-aligned for assistant)
+ if (message.content.isNotEmpty() && !isGenerating) {
Spacer(modifier = Modifier.height(Dimensions.small))
AnalyticsFooter(
message = message,
hasThinking = message.thinkingContent != null,
+ alignEnd = false,
)
}
}
-
- if (message.role == MessageRole.ASSISTANT) {
- Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing))
- }
}
-
- // Tool call detail sheet - matching iOS
+
+ // Tool call detail sheet
if (showToolCallSheet && message.toolCallInfo != null) {
com.runanywhere.runanywhereai.presentation.chat.components.ToolCallDetailSheet(
toolCallInfo = message.toolCallInfo,
@@ -913,7 +870,7 @@ fun ThinkingToggle(
overflow = TextOverflow.Ellipsis,
)
Icon(
- imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowRight,
+ imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(AppTypography.caption2.fontSize.value.dp),
tint = AppColors.primaryPurple.copy(alpha = 0.6f),
@@ -955,21 +912,22 @@ fun ThinkingToggle(
// ANALYTICS FOOTER
// ====================
-// Matches iOS timestampAndAnalyticsSection - right-aligned, timestamp always shown + optional analytics
+// Matches iOS timestampAndAnalyticsSection - timestamp always shown + optional analytics
@Composable
fun AnalyticsFooter(
message: ChatMessage,
hasThinking: Boolean,
+ alignEnd: Boolean = true,
) {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
+ horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(Dimensions.small),
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = Dimensions.mediumLarge),
+ modifier = if (alignEnd) Modifier.padding(start = Dimensions.mediumLarge) else Modifier.padding(start = Dimensions.assistantIconSize + Dimensions.assistantIconSpacing),
) {
// Timestamp - always shown ( Text(message.timestamp, style: .time))
Text(
@@ -1027,84 +985,54 @@ fun AnalyticsFooter(
// TYPING INDICATOR
// ====================
-// Typing indicator - : dots in separate bubble, text outside, centered with spacers
+// Typing indicator — simple dots + text, no bubble
@Composable
fun TypingIndicatorView() {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = Dimensions.mediumLarge),
verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Dimensions.smallMedium),
) {
- Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing))
-
Row(
- horizontalArrangement = Arrangement.spacedBy(Dimensions.mediumLarge),
+ horizontalArrangement = Arrangement.spacedBy(Dimensions.typingIndicatorDotSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
- // Dots in their own bubble background -
- Box(
- modifier = Modifier
- .shadow(
- elevation = Dimensions.shadowMedium,
- shape = RoundedCornerShape(Dimensions.large),
- ambientColor = AppColors.shadowLight,
- spotColor = AppColors.shadowLight,
- )
- .clip(RoundedCornerShape(Dimensions.large))
- .background(AppColors.typingIndicatorBackground)
- .border(
- width = Dimensions.strokeThin,
- color = AppColors.typingIndicatorBorder,
- shape = RoundedCornerShape(Dimensions.large),
- )
- .padding(
- horizontal = Dimensions.typingIndicatorPaddingHorizontal,
- vertical = Dimensions.typingIndicatorPaddingVertical,
- ),
- ) {
- Row(
- 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",
- )
+ 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,
- ),
- )
- }
- }
+ Box(
+ modifier = Modifier
+ .size(Dimensions.typingIndicatorDotSize)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+ .background(
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ shape = CircleShape,
+ ),
+ )
}
-
- // Text outside the bubble -
- Text(
- text = "AI is thinking...",
- style = AppTypography.caption,
- color = AppColors.typingIndicatorText,
- )
}
- Spacer(modifier = Modifier.width(Dimensions.messageBubbleMinSpacing))
+ Text(
+ text = "Thinking...",
+ style = AppTypography.caption,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ )
}
}
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
index 9ac2aaef7..7dd19c006 100644
--- 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
@@ -1,7 +1,7 @@
package com.runanywhere.runanywhereai.presentation.chat
import android.app.Application
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.runanywhere.runanywhereai.RunAnywhereApplication
@@ -25,12 +25,15 @@ 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.getLoadedLoraAdapters
import com.runanywhere.sdk.public.extensions.loadLLMModel
import com.runanywhere.sdk.public.extensions.LLM.ToolCallingOptions
import com.runanywhere.sdk.public.extensions.LLM.ToolCallFormat
import com.runanywhere.sdk.public.extensions.LLM.RunAnywhereToolCalling
import com.runanywhere.runanywhereai.presentation.settings.ToolSettingsViewModel
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@@ -57,6 +60,8 @@ data class ChatUiState(
val error: Throwable? = null,
val useStreaming: Boolean = true,
val currentConversation: Conversation? = null,
+ val currentModelSupportsLora: Boolean = false,
+ val hasActiveLoraAdapter: Boolean = false,
) {
val canSend: Boolean
get() = currentInput.trim().isNotEmpty() && !isGenerating && isModelLoaded
@@ -112,14 +117,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun handleLLMEvent(event: LLMEvent) {
when (event.eventType) {
LLMEvent.LLMEventType.GENERATION_STARTED -> {
- Log.d(TAG, "LLM generation started: ${event.modelId}")
+ Timber.d("LLM generation started: ${event.modelId}")
}
LLMEvent.LLMEventType.GENERATION_COMPLETED -> {
- Log.i(TAG, "✅ Generation completed: ${event.tokensGenerated} tokens")
+ Timber.i("✅ Generation completed: ${event.tokensGenerated} tokens")
_uiState.value = _uiState.value.copy(isGenerating = false)
}
LLMEvent.LLMEventType.GENERATION_FAILED -> {
- Log.e(TAG, "Generation failed: ${event.error}")
+ Timber.e("Generation failed: ${event.error}")
_uiState.value =
_uiState.value.copy(
isGenerating = false,
@@ -130,7 +135,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Token received during streaming - handled by flow collection
}
LLMEvent.LLMEventType.STREAM_COMPLETED -> {
- Log.d(TAG, "Stream completed")
+ Timber.d("Stream completed")
}
}
}
@@ -142,18 +147,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fun sendMessage() {
val currentState = _uiState.value
- Log.i(TAG, "🎯 sendMessage() called")
- Log.i(TAG, "📝 canSend: ${currentState.canSend}, isModelLoaded: ${currentState.isModelLoaded}, loadedModelName: ${currentState.loadedModelName}")
+ Timber.i("🎯 sendMessage() called")
+ Timber.i("📝 canSend: ${currentState.canSend}, isModelLoaded: ${currentState.isModelLoaded}, loadedModelName: ${currentState.loadedModelName}")
if (!currentState.canSend) {
- Log.w(TAG, "Cannot send message - canSend is false")
+ Timber.w("Cannot send message - canSend is false")
return
}
- Log.i(TAG, "✅ canSend is true, proceeding")
+ Timber.i("✅ canSend is true, proceeding")
val prompt = currentState.currentInput
- Log.i(TAG, "🎯 Sending message: ${prompt.take(50)}...")
+ Timber.i("🎯 Sending message: ${prompt.take(50)}...")
// Clear input and set generating state
_uiState.value =
@@ -206,7 +211,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val registeredTools = RunAnywhereToolCalling.getRegisteredTools()
if (useToolCalling && registeredTools.isNotEmpty()) {
- Log.i(TAG, "🔧 Using tool calling with ${registeredTools.size} tools")
+ Timber.i("🔧 Using tool calling with ${registeredTools.size} tools")
generateWithToolCalling(prompt, assistantMessage.id)
} else if (currentState.useStreaming) {
generateWithStreaming(prompt, assistantMessage.id)
@@ -234,12 +239,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Note: loadedModelName can be null if model state changes during generation
val modelName = _uiState.value.loadedModelName
if (modelName == null) {
- Log.w(TAG, "⚠️ Tool calling initiated but model name is null, using default format")
+ Timber.w("⚠️ Tool calling initiated but model name is null, using default format")
}
val toolViewModel = ToolSettingsViewModel.getInstance(app)
val format = toolViewModel.detectToolCallFormat(modelName)
- Log.i(TAG, "🔧 Tool calling with format: $format for model: ${modelName ?: "unknown"}")
+ Timber.i("🔧 Tool calling with format: $format for model: ${modelName ?: "unknown"}")
// Create tool calling options
val toolOptions = ToolCallingOptions(
@@ -260,9 +265,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Log tool calls and create tool call info
if (result.toolCalls.isNotEmpty()) {
- Log.i(TAG, "🔧 Tool calls made: ${result.toolCalls.map { it.toolName }}")
+ Timber.i("🔧 Tool calls made: ${result.toolCalls.map { it.toolName }}")
result.toolResults.forEach { toolResult ->
- Log.i(TAG, "📋 Tool result: ${toolResult.toolName} - success: ${toolResult.success}")
+ Timber.i("📋 Tool result: ${toolResult.toolName} - success: ${toolResult.success}")
}
// Create ToolCallInfo from the first tool call and result
@@ -296,7 +301,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateAssistantMessageWithAnalytics(messageId, analytics)
} catch (e: Exception) {
- Log.e(TAG, "Tool calling failed", e)
+ Timber.e(e, "Tool calling failed")
throw e
} finally {
_uiState.value = _uiState.value.copy(isGenerating = false)
@@ -323,7 +328,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
var totalTokensReceived = 0
var wasInterrupted = false
- Log.i(TAG, "📤 Starting streaming generation")
+ Timber.i("📤 Starting streaming generation")
try {
// Use SDK streaming generation - returns Flow
@@ -349,7 +354,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (fullResponse.contains("") && !isInThinkingMode) {
isInThinkingMode = true
thinkingStartTime = System.currentTimeMillis()
- Log.i(TAG, "🧠 Entering thinking mode")
+ Timber.i("🧠 Entering thinking mode")
}
if (isInThinkingMode) {
@@ -363,7 +368,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
responseContent = fullResponse.substring(thinkingEndRange + 8)
isInThinkingMode = false
thinkingEndTime = System.currentTimeMillis()
- Log.i(TAG, "🧠 Exiting thinking mode")
+ Timber.i("🧠 Exiting thinking mode")
}
} else {
// Still in thinking mode
@@ -385,7 +390,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
}
} catch (e: Exception) {
- Log.e(TAG, "Streaming failed", e)
+ Timber.e(e, "Streaming failed")
wasInterrupted = true
throw e
}
@@ -394,7 +399,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Handle edge case: Stream ended while still in thinking mode
if (isInThinkingMode && !fullResponse.contains("")) {
- Log.w(TAG, "⚠️ Stream ended while in thinking mode")
+ Timber.w("⚠️ Stream ended while in thinking mode")
wasInterrupted = true
if (thinkingContent.isNotEmpty()) {
@@ -438,7 +443,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
syncCurrentConversationToStore()
_uiState.value = _uiState.value.copy(isGenerating = false)
- Log.i(TAG, "✅ Streaming generation completed")
+ Timber.i("✅ Streaming generation completed")
}
/**
@@ -487,7 +492,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
error: Exception,
messageId: String,
) {
- Log.e(TAG, "❌ Generation failed", error)
+ Timber.e(error, "❌ Generation failed")
val errorMessage =
when {
@@ -723,12 +728,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (RunAnywhere.isLLMModelLoaded()) {
val currentModel = RunAnywhere.currentLLMModel()
val displayName = currentModel?.name ?: RunAnywhere.currentLLMModelId
- Log.i(TAG, "✅ LLM model already loaded: $displayName")
+ Timber.i("✅ LLM model already loaded: $displayName")
_uiState.value =
_uiState.value.copy(
isModelLoaded = true,
loadedModelName = displayName,
+ currentModelSupportsLora = currentModel?.supportsLora == true,
)
+ refreshLoraState()
addSystemMessageIfNeeded()
return
}
@@ -741,7 +748,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
if (chatModel != null) {
- Log.i(TAG, "📦 Found downloaded chat model: ${chatModel.name}, loading...")
+ Timber.i("📦 Found downloaded chat model: ${chatModel.name}, loading...")
try {
// Load the chat model into memory
@@ -751,11 +758,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_uiState.value.copy(
isModelLoaded = true,
loadedModelName = chatModel.name,
+ currentModelSupportsLora = chatModel.supportsLora,
)
- Log.i(TAG, "✅ Chat model loaded successfully: ${chatModel.name}")
+ refreshLoraState()
+ Timber.i("✅ 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)
+ Timber.e(e, "❌ Failed to load chat model: ${e.message}")
_uiState.value =
_uiState.value.copy(
isModelLoaded = false,
@@ -769,7 +778,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isModelLoaded = false,
loadedModelName = null,
)
- Log.i(TAG, "ℹ️ No downloaded chat models found.")
+ Timber.i("ℹ️ No downloaded chat models found.")
}
addSystemMessageIfNeeded()
@@ -779,11 +788,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isModelLoaded = false,
loadedModelName = null,
)
- Log.i(TAG, "❌ SDK not ready")
+ Timber.i("❌ 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)
+ Timber.e(e, "Failed to check model status: ${e.message}")
_uiState.value =
_uiState.value.copy(
isModelLoaded = false,
@@ -793,6 +802,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
+ /** Refresh LoRA loaded state for the active adapters indicator. */
+ private var loraRefreshJob: Job? = null
+ fun refreshLoraState() {
+ loraRefreshJob?.cancel()
+ loraRefreshJob = viewModelScope.launch {
+ try {
+ val loaded = withContext(Dispatchers.IO) { RunAnywhere.getLoadedLoraAdapters() }
+ _uiState.value = _uiState.value.copy(hasActiveLoraAdapter = loaded.isNotEmpty())
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to refresh LoRA state")
+ }
+ }
+ }
+
/**
* Helper to add system message if model is loaded and not already present.
*/
@@ -819,7 +842,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {
_uiState.value = _uiState.value.copy(messages = loaded.messages)
val analyticsCount = loaded.messages.mapNotNull { it.analytics }.size
- Log.i(TAG, "📂 Loaded conversation with ${loaded.messages.size} messages, $analyticsCount have analytics")
+ Timber.i("📂 Loaded conversation with ${loaded.messages.size} messages, $analyticsCount have analytics")
}
loaded.modelName?.let { modelName ->
@@ -860,7 +883,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val systemPrompt = if (systemPromptValue.isNullOrEmpty()) null else systemPromptValue
val systemPromptInfo = systemPrompt?.let { "set(${it.length} chars)" } ?: "nil"
- Log.i(TAG, "[PARAMS] App getGenerationOptions: temperature=$temperature, maxTokens=$maxTokens, systemPrompt=$systemPromptInfo")
+ Timber.i("[PARAMS] App getGenerationOptions: temperature=$temperature, maxTokens=$maxTokens, systemPrompt=$systemPromptInfo")
return com.runanywhere.sdk.public.extensions.LLM.LLMGenerationOptions(
maxTokens = maxTokens,
@@ -901,7 +924,4 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
- 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
index 8ff0ed189..69ea53f71 100644
--- 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
@@ -1,7 +1,5 @@
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
@@ -14,7 +12,6 @@ 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
@@ -23,15 +20,17 @@ 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.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
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.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -58,7 +57,6 @@ fun MarkdownText(
color: Color = Color.Unspecified,
) {
val blocks = remember(markdown) { parseMarkdownBlocks(markdown) }
- val context = LocalContext.current
Column(modifier = modifier) {
blocks.forEachIndexed { index, block ->
@@ -78,16 +76,9 @@ fun MarkdownText(
else -> style.copy(fontWeight = FontWeight.SemiBold)
}
val annotated = parseInlineMarkdown(block.text, color)
- ClickableText(
+ Text(
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)
- }
- },
)
}
@@ -100,17 +91,10 @@ fun MarkdownText(
)
Spacer(modifier = Modifier.width(8.dp))
val annotated = parseInlineMarkdown(block.text, color)
- ClickableText(
+ Text(
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)
- }
- },
)
}
}
@@ -124,17 +108,10 @@ fun MarkdownText(
)
Spacer(modifier = Modifier.width(8.dp))
val annotated = parseInlineMarkdown(block.text, color)
- ClickableText(
+ Text(
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)
- }
- },
)
}
}
@@ -157,33 +134,19 @@ fun MarkdownText(
)
Spacer(modifier = Modifier.width(8.dp))
val annotated = parseInlineMarkdown(block.text, color)
- ClickableText(
+ Text(
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(
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)
- }
- },
)
}
}
@@ -254,9 +217,7 @@ private fun CodeBlockView(
}
}
-// ============================================================
// Markdown Parsing
-// ============================================================
private sealed class MarkdownBlock {
data class Paragraph(val text: String) : MarkdownBlock()
@@ -451,16 +412,19 @@ private fun parseInlineMarkdown(text: String, defaultColor: Color): AnnotatedStr
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,
+ withLink(
+ LinkAnnotation.Url(
+ url,
+ TextLinkStyles(
+ style = SpanStyle(
+ color = Color(0xFF3B82F6),
+ textDecoration = TextDecoration.Underline,
+ ),
+ ),
),
) {
append(linkText)
}
- pop()
i = closeParen + 1
} else {
append('[')
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
index 3b5222a5d..e76c3463e 100644
--- 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
@@ -4,7 +4,7 @@ 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.material.icons.automirrored.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -67,7 +67,7 @@ fun MessageInput(
enabled = enabled && text.isNotBlank(),
) {
Icon(
- Icons.Default.Send,
+ Icons.AutoMirrored.Filled.Send,
contentDescription = "Send message",
)
}
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
index 4ed48ba20..f76ac92af 100644
--- 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
@@ -22,12 +22,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
-import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
@@ -248,7 +248,7 @@ 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.TTS -> Icons.AutoMirrored.Filled.VolumeUp
ModelSelectionContext.VOICE -> Icons.Default.Mic
ModelSelectionContext.RAG_EMBEDDING,
ModelSelectionContext.RAG_LLM -> Icons.Default.Description
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/components/TopBarState.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/components/TopBarState.kt
new file mode 100644
index 000000000..936d0230a
--- /dev/null
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/components/TopBarState.kt
@@ -0,0 +1,58 @@
+package com.runanywhere.runanywhereai.presentation.components
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/**
+ * Holds dynamic top bar state driven by the currently visible screen.
+ * Provided via [LocalTopBarState] from the root Scaffold in AppNavigation.
+ */
+@Stable
+class TopBarState {
+ var title by mutableStateOf("")
+ var showBack by mutableStateOf(false)
+ var onBack by mutableStateOf<(() -> Unit)?>(null)
+ var actions by mutableStateOf<(@Composable RowScope.() -> Unit)>({})
+ var customTopBar by mutableStateOf<(@Composable () -> Unit)?>(null)
+}
+
+val LocalTopBarState = staticCompositionLocalOf { error("No TopBarState provided") }
+
+/**
+ * Standard screens call this to configure the shared top bar.
+ * Sets [TopBarState.customTopBar] to null so the default TopAppBar is used.
+ */
+@Composable
+fun ConfigureTopBar(
+ title: String,
+ showBack: Boolean = false,
+ onBack: (() -> Unit)? = null,
+ actions: @Composable RowScope.() -> Unit = {},
+) {
+ val state = LocalTopBarState.current
+ SideEffect {
+ state.title = title
+ state.showBack = showBack
+ state.onBack = onBack
+ state.actions = actions
+ state.customTopBar = null
+ }
+}
+
+/**
+ * Screens with a fully custom top bar (Chat, VLM) call this.
+ * The root Scaffold renders [content] instead of the default TopAppBar.
+ */
+@Composable
+fun ConfigureCustomTopBar(content: @Composable () -> Unit) {
+ val state = LocalTopBarState.current
+ SideEffect {
+ state.customTopBar = content
+ }
+}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraAdapterPickerSheet.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraAdapterPickerSheet.kt
new file mode 100644
index 000000000..5b0ae15ee
--- /dev/null
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraAdapterPickerSheet.kt
@@ -0,0 +1,337 @@
+package com.runanywhere.runanywhereai.presentation.lora
+
+import androidx.compose.foundation.background
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+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.clip
+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.Dimensions
+import com.runanywhere.sdk.public.extensions.LoraAdapterCatalogEntry
+import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterInfo
+
+/**
+ * Bottom sheet for picking and managing LoRA adapters for the current model.
+ * Shows active adapters with remove option, and compatible adapters with download/apply.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LoraAdapterPickerSheet(
+ loraViewModel: LoraViewModel,
+ onDismiss: () -> Unit,
+) {
+ val state by loraViewModel.uiState.collectAsState()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = Dimensions.large),
+ ) {
+ // Header
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "LoRA Adapters",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ TextButton(onClick = onDismiss) {
+ Text("Done")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(Dimensions.mediumLarge))
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(Dimensions.smallMedium),
+ ) {
+ // Active adapters section
+ if (state.loadedAdapters.isNotEmpty()) {
+ item {
+ SectionHeader("Active Adapters")
+ }
+ items(state.loadedAdapters, key = { it.path }) { adapter ->
+ LoadedAdapterRow(
+ adapter = adapter,
+ onRemove = { loraViewModel.unloadAdapter(adapter.path) },
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(Dimensions.small))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(Dimensions.small))
+ }
+ }
+
+ // Compatible adapters section
+ item {
+ SectionHeader("Compatible Adapters")
+ }
+
+ if (state.compatibleAdapters.isEmpty()) {
+ item {
+ Text(
+ "No compatible adapters found for this model.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = Dimensions.mediumLarge),
+ )
+ }
+ } else {
+ items(state.compatibleAdapters, key = { it.id }) { entry ->
+ CatalogAdapterRow(
+ entry = entry,
+ isDownloaded = loraViewModel.isDownloaded(entry),
+ isLoaded = loraViewModel.isLoaded(entry),
+ isDownloading = state.downloadingAdapterId == entry.id,
+ downloadProgress = if (state.downloadingAdapterId == entry.id) state.downloadProgress else 0f,
+ onDownload = { loraViewModel.downloadAdapter(entry) },
+ onCancelDownload = { loraViewModel.cancelDownload() },
+ onApply = { scale ->
+ val path = loraViewModel.localPath(entry) ?: return@CatalogAdapterRow
+ loraViewModel.loadAdapter(path, scale)
+ },
+ onRemove = {
+ val path = loraViewModel.localPath(entry) ?: return@CatalogAdapterRow
+ loraViewModel.unloadAdapter(path)
+ },
+ )
+ }
+ }
+
+ // Error display
+ state.error?.let { error ->
+ item {
+ Text(
+ error,
+ color = AppColors.primaryRed,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(vertical = Dimensions.small),
+ )
+ }
+ }
+
+ // Bottom spacing
+ item { Spacer(modifier = Modifier.height(Dimensions.xxLarge)) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(title: String) {
+ Text(
+ title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(vertical = Dimensions.xSmall),
+ )
+}
+
+@Composable
+private fun LoadedAdapterRow(
+ adapter: LoRAAdapterInfo,
+ onRemove: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(Dimensions.cornerRadiusRegular))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .padding(Dimensions.mediumLarge),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ adapter.path.substringAfterLast("/"),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ "Scale: %.2f".format(adapter.scale),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(onClick = onRemove) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove",
+ tint = AppColors.primaryRed,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CatalogAdapterRow(
+ entry: LoraAdapterCatalogEntry,
+ isDownloaded: Boolean,
+ isLoaded: Boolean,
+ isDownloading: Boolean,
+ downloadProgress: Float,
+ onDownload: () -> Unit,
+ onCancelDownload: () -> Unit,
+ onApply: (Float) -> Unit,
+ onRemove: () -> Unit,
+) {
+ var scale by remember(entry.id, entry.defaultScale) { mutableFloatStateOf(entry.defaultScale) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(Dimensions.cornerRadiusRegular))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .padding(Dimensions.mediumLarge),
+ ) {
+ // Name + description
+ Text(
+ entry.name,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ entry.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
+
+ // File size
+ Text(
+ "Size: %.1f MB".format(entry.fileSize / (1024.0 * 1024.0)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
+
+ if (isDownloaded && !isLoaded) {
+ // Scale slider + Apply button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Scale:", style = MaterialTheme.typography.labelSmall)
+ Spacer(modifier = Modifier.width(Dimensions.smallMedium))
+ Slider(
+ value = scale,
+ onValueChange = { scale = it },
+ valueRange = 0f..2f,
+ modifier = Modifier
+ .weight(1f)
+ .height(Dimensions.loraScaleSliderHeight),
+ colors = SliderDefaults.colors(
+ thumbColor = AppColors.primaryPurple,
+ activeTrackColor = AppColors.primaryPurple,
+ ),
+ )
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("%.1f".format(scale), style = MaterialTheme.typography.labelSmall)
+ }
+ Button(
+ onClick = { onApply(scale) },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryPurple),
+ ) {
+ Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("Apply")
+ }
+ } else if (isLoaded) {
+ // Already loaded — show remove
+ Button(
+ onClick = onRemove,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryRed),
+ ) {
+ Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("Remove")
+ }
+ } else if (isDownloading) {
+ // Download progress
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ progress = { downloadProgress },
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ color = AppColors.primaryPurple,
+ )
+ Spacer(modifier = Modifier.width(Dimensions.smallMedium))
+ Text(
+ "${(downloadProgress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(modifier = Modifier.width(Dimensions.smallMedium))
+ IconButton(onClick = onCancelDownload, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Close, contentDescription = "Cancel download", modifier = Modifier.size(16.dp))
+ }
+ }
+ } else {
+ // Not downloaded — show download button
+ Button(
+ onClick = onDownload,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryAccent),
+ ) {
+ Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("Download")
+ }
+ }
+ }
+}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraManagerScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraManagerScreen.kt
new file mode 100644
index 000000000..fadcb645f
--- /dev/null
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraManagerScreen.kt
@@ -0,0 +1,320 @@
+package com.runanywhere.runanywhereai.presentation.lora
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.DeleteForever
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
+import com.runanywhere.runanywhereai.ui.theme.AppColors
+import com.runanywhere.runanywhereai.ui.theme.Dimensions
+import com.runanywhere.sdk.public.extensions.LoraAdapterCatalogEntry
+
+/**
+ * Full LoRA adapter manager screen — accessible from More hub.
+ * Shows currently loaded adapters and all registered adapters with download/delete.
+ */
+@Composable
+fun LoraManagerScreen(
+ onBack: () -> Unit = {},
+ loraViewModel: LoraViewModel = viewModel(),
+) {
+ val state by loraViewModel.uiState.collectAsState()
+
+ ConfigureTopBar(title = "LoRA Adapters", showBack = true, onBack = onBack)
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = Dimensions.large),
+ verticalArrangement = Arrangement.spacedBy(Dimensions.smallMedium),
+ ) {
+ // Currently loaded section
+ if (state.loadedAdapters.isNotEmpty()) {
+ item {
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
+ Text(
+ "Currently Loaded",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ items(state.loadedAdapters, key = { it.path }) { adapter ->
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(Dimensions.cornerRadiusXLarge),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(Dimensions.large),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ adapter.path.substringAfterLast("/"),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ "Scale: ${"%.2f".format(adapter.scale)} | ${if (adapter.applied) "Applied" else "Pending"}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(onClick = { loraViewModel.unloadAdapter(adapter.path) }) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Remove",
+ tint = AppColors.primaryRed,
+ )
+ }
+ }
+ }
+ }
+
+ // Clear all button
+ item {
+ Button(
+ onClick = { loraViewModel.clearAll() },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryRed.copy(alpha = 0.1f)),
+ ) {
+ Icon(
+ Icons.Default.DeleteForever,
+ contentDescription = null,
+ tint = AppColors.primaryRed,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("Clear All Adapters", color = AppColors.primaryRed)
+ }
+ }
+ }
+
+ // All registered adapters section
+ item {
+ Spacer(modifier = Modifier.height(Dimensions.mediumLarge))
+ Text(
+ "All Adapters",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+
+ if (state.registeredAdapters.isEmpty()) {
+ item {
+ Text(
+ "No adapters registered. Adapters are registered at app startup.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = Dimensions.large),
+ )
+ }
+ } else {
+ items(state.registeredAdapters, key = { it.id }) { entry ->
+ RegisteredAdapterCard(
+ entry = entry,
+ isDownloaded = loraViewModel.isDownloaded(entry),
+ isDownloading = state.downloadingAdapterId == entry.id,
+ downloadProgress = if (state.downloadingAdapterId == entry.id) state.downloadProgress else 0f,
+ onDownload = { loraViewModel.downloadAdapter(entry) },
+ onCancelDownload = { loraViewModel.cancelDownload() },
+ onDelete = { loraViewModel.deleteAdapter(entry) },
+ )
+ }
+ }
+
+ // Error
+ state.error?.let { error ->
+ item {
+ Text(
+ error,
+ color = AppColors.primaryRed,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(vertical = Dimensions.small),
+ )
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(Dimensions.xxLarge)) }
+ }
+}
+
+@Composable
+private fun RegisteredAdapterCard(
+ entry: LoraAdapterCatalogEntry,
+ isDownloaded: Boolean,
+ isDownloading: Boolean,
+ downloadProgress: Float,
+ onDownload: () -> Unit,
+ onCancelDownload: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(Dimensions.cornerRadiusXLarge),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(Dimensions.large),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ entry.name,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ entry.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ // LoRA badge
+ Text(
+ "LoRA",
+ style = MaterialTheme.typography.labelSmall,
+ color = AppColors.primaryPurple,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier
+ .clip(RoundedCornerShape(Dimensions.cornerRadiusSmall))
+ .background(AppColors.loraBadgeBg)
+ .padding(horizontal = Dimensions.small, vertical = Dimensions.xxSmall),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
+
+ // Compatible models chips
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(Dimensions.xSmall),
+ ) {
+ entry.compatibleModelIds.forEach { modelId ->
+ Text(
+ modelId,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .clip(RoundedCornerShape(Dimensions.cornerRadiusSmall))
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(horizontal = Dimensions.small, vertical = Dimensions.xxSmall),
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(Dimensions.xSmall))
+
+ // File size
+ Text(
+ "%.1f MB".format(entry.fileSize / (1024.0 * 1024.0)),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
+
+ // Action button
+ when {
+ isDownloading -> {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ progress = { downloadProgress },
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ color = AppColors.primaryPurple,
+ )
+ Spacer(modifier = Modifier.width(Dimensions.smallMedium))
+ Text(
+ "Downloading ${(downloadProgress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(modifier = Modifier.width(Dimensions.smallMedium))
+ IconButton(onClick = onCancelDownload, modifier = Modifier.size(24.dp)) {
+ Icon(Icons.Default.Close, contentDescription = "Cancel download", modifier = Modifier.size(16.dp))
+ }
+ }
+ }
+ isDownloaded -> {
+ Button(
+ onClick = onDelete,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = AppColors.primaryRed.copy(alpha = 0.1f),
+ ),
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = null,
+ tint = AppColors.primaryRed,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("Delete Download", color = AppColors.primaryRed)
+ }
+ }
+ else -> {
+ Button(
+ onClick = onDownload,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryAccent),
+ ) {
+ Icon(
+ Icons.Default.CloudDownload,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(Dimensions.xSmall))
+ Text("Download")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraViewModel.kt
new file mode 100644
index 000000000..b1cd7e519
--- /dev/null
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/lora/LoraViewModel.kt
@@ -0,0 +1,346 @@
+package com.runanywhere.runanywhereai.presentation.lora
+
+import android.app.Application
+import timber.log.Timber
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.runanywhere.sdk.public.RunAnywhere
+import com.runanywhere.sdk.public.extensions.LoraAdapterCatalogEntry
+import com.runanywhere.sdk.public.extensions.LoraCompatibilityResult
+import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterConfig
+import com.runanywhere.sdk.public.extensions.LLM.LoRAAdapterInfo
+import com.runanywhere.sdk.public.extensions.allRegisteredLoraAdapters
+import com.runanywhere.sdk.public.extensions.checkLoraCompatibility
+import com.runanywhere.sdk.public.extensions.clearLoraAdapters
+import com.runanywhere.sdk.public.extensions.getLoadedLoraAdapters
+import com.runanywhere.sdk.public.extensions.loadLoraAdapter
+import com.runanywhere.sdk.public.extensions.loraAdaptersForModel
+import com.runanywhere.sdk.public.extensions.removeLoraAdapter
+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.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.net.URI
+
+data class LoraUiState(
+ val registeredAdapters: List = emptyList(),
+ val loadedAdapters: List = emptyList(),
+ val compatibleAdapters: List = emptyList(),
+ val downloadedAdapterPaths: Map = emptyMap(),
+ val downloadingAdapterId: String? = null,
+ val downloadProgress: Float = 0f,
+ val error: String? = null,
+)
+
+/**
+ * ViewModel for LoRA adapter management.
+ * Handles listing, downloading, loading, and removing LoRA adapters.
+ */
+class LoraViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val _uiState = MutableStateFlow(LoraUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+ private var downloadJob: Job? = null
+ private val downloadMutex = Mutex()
+
+ private val loraDir: File by lazy {
+ File(application.filesDir, "lora_adapters").also { it.mkdirs() }
+ }
+
+ init {
+ refresh()
+ }
+
+ /** Refresh all registered and loaded adapters. */
+ fun refresh() {
+ viewModelScope.launch {
+ try {
+ val (registered, loaded, downloaded) = withContext(Dispatchers.IO) {
+ val reg = RunAnywhere.allRegisteredLoraAdapters()
+ Triple(reg, RunAnywhere.getLoadedLoraAdapters(), scanDownloadedAdapters(reg))
+ }
+ _uiState.update {
+ it.copy(
+ registeredAdapters = registered,
+ loadedAdapters = loaded,
+ downloadedAdapterPaths = downloaded,
+ error = null,
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to refresh LoRA state")
+ _uiState.update { it.copy(error = e.message) }
+ }
+ }
+ }
+
+ /** Refresh compatible adapters for a specific model. */
+ fun refreshForModel(modelId: String) {
+ viewModelScope.launch {
+ try {
+ val (compatible, loaded, downloaded) = withContext(Dispatchers.IO) {
+ val compat = RunAnywhere.loraAdaptersForModel(modelId)
+ Triple(compat, RunAnywhere.getLoadedLoraAdapters(), scanDownloadedAdapters(compat))
+ }
+ _uiState.update {
+ it.copy(
+ compatibleAdapters = compatible,
+ loadedAdapters = loaded,
+ downloadedAdapterPaths = it.downloadedAdapterPaths + downloaded,
+ error = null,
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to refresh for model $modelId")
+ _uiState.update { it.copy(error = e.message) }
+ }
+ }
+ }
+
+ /** Load a LoRA adapter from a local file path. */
+ fun loadAdapter(path: String, scale: Float = 1.0f) {
+ viewModelScope.launch {
+ try {
+ val config = LoRAAdapterConfig(path = path, scale = scale)
+ withContext(Dispatchers.IO) { RunAnywhere.loadLoraAdapter(config) }
+ val loaded = withContext(Dispatchers.IO) { RunAnywhere.getLoadedLoraAdapters() }
+ _uiState.update { it.copy(loadedAdapters = loaded, error = null) }
+ Timber.i("Loaded LoRA adapter: $path (scale=$scale)")
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to load LoRA adapter")
+ _uiState.update { it.copy(error = "Failed to load adapter: ${e.message}") }
+ }
+ }
+ }
+
+ /** Remove a specific loaded adapter by path. */
+ fun unloadAdapter(path: String) {
+ viewModelScope.launch {
+ try {
+ withContext(Dispatchers.IO) { RunAnywhere.removeLoraAdapter(path) }
+ val loaded = withContext(Dispatchers.IO) { RunAnywhere.getLoadedLoraAdapters() }
+ _uiState.update { it.copy(loadedAdapters = loaded, error = null) }
+ Timber.i("Unloaded LoRA adapter: $path")
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to unload LoRA adapter")
+ _uiState.update { it.copy(error = "Failed to unload adapter: ${e.message}") }
+ }
+ }
+ }
+
+ /** Clear all loaded adapters. */
+ fun clearAll() {
+ viewModelScope.launch {
+ try {
+ withContext(Dispatchers.IO) { RunAnywhere.clearLoraAdapters() }
+ _uiState.update { it.copy(loadedAdapters = emptyList(), error = null) }
+ Timber.i("Cleared all LoRA adapters")
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to clear LoRA adapters")
+ _uiState.update { it.copy(error = e.message) }
+ }
+ }
+ }
+
+ /** Check if a LoRA adapter file is compatible with the current model. */
+ fun checkCompatibility(loraPath: String, onResult: (LoraCompatibilityResult) -> Unit) {
+ viewModelScope.launch {
+ val result = withContext(Dispatchers.IO) {
+ RunAnywhere.checkLoraCompatibility(loraPath)
+ }
+ onResult(result)
+ }
+ }
+
+ /** Get the local file path for a catalog entry, or null if not downloaded (reads from cached state). */
+ fun localPath(entry: LoraAdapterCatalogEntry): String? {
+ return _uiState.value.downloadedAdapterPaths[entry.id]
+ }
+
+ /** Check if a catalog entry is already downloaded (reads from cached state). */
+ fun isDownloaded(entry: LoraAdapterCatalogEntry): Boolean {
+ return entry.id in _uiState.value.downloadedAdapterPaths
+ }
+
+ /** Check if a specific adapter is currently loaded. */
+ fun isLoaded(entry: LoraAdapterCatalogEntry): Boolean {
+ val path = localPath(entry) ?: return false
+ return _uiState.value.loadedAdapters.any { it.path == path }
+ }
+
+ /** Scan disk for downloaded adapter files and return id->path map. Must be called on IO dispatcher. */
+ private fun scanDownloadedAdapters(adapters: List): Map {
+ return adapters.mapNotNull { entry ->
+ val file = File(loraDir, entry.filename)
+ // Validate resolved path stays under loraDir to prevent path traversal
+ if (!file.canonicalPath.startsWith(loraDir.canonicalPath + File.separator)) {
+ Timber.w("Skipping adapter with invalid filename (path traversal): ${entry.filename}")
+ return@mapNotNull null
+ }
+ if (file.exists()) entry.id to file.absolutePath else null
+ }.toMap()
+ }
+
+ /** Download a LoRA adapter GGUF file. */
+ fun downloadAdapter(entry: LoraAdapterCatalogEntry) {
+ viewModelScope.launch {
+ // Mutex ensures only one download starts even under concurrent calls
+ if (!downloadMutex.tryLock()) return@launch
+ try {
+ if (_uiState.value.downloadingAdapterId != null) return@launch
+ startDownload(entry)
+ } finally {
+ downloadMutex.unlock()
+ }
+ }
+ }
+
+ private fun startDownload(entry: LoraAdapterCatalogEntry) {
+ val destFile = File(loraDir, entry.filename)
+ // Validate resolved path stays under loraDir
+ if (!destFile.canonicalPath.startsWith(loraDir.canonicalPath + File.separator)) {
+ _uiState.update { it.copy(error = "Invalid adapter filename") }
+ return
+ }
+ // Only allow HTTPS downloads to prevent MITM attacks
+ val uri = try { URI(entry.downloadUrl) } catch (_: Exception) { null }
+ if (uri == null || uri.scheme?.lowercase() != "https") {
+ _uiState.update { it.copy(error = "Only HTTPS download URLs are allowed") }
+ return
+ }
+
+ _uiState.update {
+ it.copy(
+ downloadingAdapterId = entry.id,
+ downloadProgress = 0f,
+ error = null,
+ )
+ }
+
+ downloadJob = viewModelScope.launch {
+ val tmpFile = File(loraDir, "${entry.filename}.tmp")
+ var downloadComplete = false
+ try {
+ withContext(Dispatchers.IO) {
+ val connection = URI(entry.downloadUrl).toURL().openConnection().apply {
+ connectTimeout = 30_000
+ readTimeout = 60_000
+ }
+ connection.connect()
+ val totalSize = connection.contentLengthLong.takeIf { it > 0 } ?: entry.fileSize
+ var downloaded = 0L
+
+ connection.getInputStream().buffered().use { input ->
+ tmpFile.outputStream().buffered().use { output ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ ensureActive()
+ output.write(buffer, 0, bytesRead)
+ downloaded += bytesRead
+ if (totalSize > 0) {
+ val progress = (downloaded.toFloat() / totalSize).coerceIn(0f, 1f)
+ _uiState.update { it.copy(downloadProgress = progress) }
+ }
+ }
+ }
+ }
+ // Validate downloaded file size if catalog provides one
+ if (entry.fileSize > 0 && tmpFile.length() != entry.fileSize) {
+ tmpFile.delete()
+ throw Exception(
+ "Downloaded file size (${tmpFile.length()}) does not match expected size (${entry.fileSize})"
+ )
+ }
+ destFile.delete()
+ if (!tmpFile.renameTo(destFile)) {
+ tmpFile.delete()
+ throw Exception("Failed to move downloaded file to final location")
+ }
+ downloadComplete = true
+ }
+
+ Timber.i("Downloaded LoRA adapter: ${entry.name} -> ${destFile.absolutePath}")
+ _uiState.update {
+ it.copy(
+ downloadingAdapterId = null,
+ downloadProgress = 0f,
+ downloadedAdapterPaths = it.downloadedAdapterPaths + (entry.id to destFile.absolutePath),
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to download LoRA adapter: ${entry.name}")
+ _uiState.update {
+ it.copy(
+ downloadingAdapterId = null,
+ downloadProgress = 0f,
+ error = "Download failed: ${e.message}",
+ )
+ }
+ } finally {
+ if (!downloadComplete && tmpFile.exists()) {
+ tmpFile.delete()
+ }
+ }
+ }
+ }
+
+ /** Cancel an in-progress download. */
+ fun cancelDownload() {
+ downloadJob?.cancel()
+ downloadJob = null
+ _uiState.update {
+ it.copy(
+ downloadingAdapterId = null,
+ downloadProgress = 0f,
+ )
+ }
+ }
+
+ /** Delete a downloaded adapter file. Always attempts unload first (ignores if not loaded). */
+ fun deleteAdapter(entry: LoraAdapterCatalogEntry) {
+ viewModelScope.launch {
+ try {
+ val file = File(loraDir, entry.filename)
+ // Validate resolved path stays under loraDir
+ if (!file.canonicalPath.startsWith(loraDir.canonicalPath + File.separator)) {
+ _uiState.update { it.copy(error = "Invalid adapter filename") }
+ return@launch
+ }
+ withContext(Dispatchers.IO) {
+ // Always try to unload — ignore errors if not loaded
+ try {
+ RunAnywhere.removeLoraAdapter(file.absolutePath)
+ Timber.i("Unloaded LoRA adapter before delete: ${entry.filename}")
+ } catch (_: Exception) { /* not loaded, safe to ignore */ }
+ if (file.exists()) {
+ file.delete()
+ Timber.i("Deleted LoRA adapter file: ${entry.filename}")
+ }
+ }
+ val loaded = withContext(Dispatchers.IO) { RunAnywhere.getLoadedLoraAdapters() }
+ _uiState.update {
+ it.copy(
+ loadedAdapters = loaded,
+ downloadedAdapterPaths = it.downloadedAdapterPaths - entry.id,
+ )
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to delete adapter: ${entry.filename}")
+ _uiState.update { it.copy(error = "Delete failed: ${e.message}") }
+ }
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(error = null) }
+ }
+}
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
index b8c422b5a..4df64adfe 100644
--- 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
@@ -78,6 +78,7 @@ private data class AIModel(
val formatColor: Color,
val size: String,
val isDownloaded: Boolean,
+ val supportsLora: Boolean = false,
)
/**
@@ -255,6 +256,7 @@ private fun toAIModel(m: ModelInfo): AIModel {
formatColor = formatColor,
size = sizeStr,
isDownloaded = m.isDownloaded || m.framework == InferenceFramework.FOUNDATION_MODELS || m.framework == InferenceFramework.SYSTEM_TTS,
+ supportsLora = m.supportsLora,
)
}
@@ -280,7 +282,7 @@ private fun formatBytes(bytes: Long): String {
return if (gb >= 1.0) String.format("%.2f GB", gb) else String.format("%.0f MB", bytes / (1024.0 * 1024.0))
}
-// ── Header ──
+// Header
@Composable
private fun SheetHeader(
@@ -312,7 +314,7 @@ private fun SheetHeader(
}
}
-// ── Section Label ──
+// Section Label
@Composable
private fun SectionLabel(text: String) {
@@ -325,7 +327,7 @@ private fun SectionLabel(text: String) {
)
}
-// ── Device Status Card ──
+// Device Status Card
@Composable
private fun DeviceStatusCard(status: DeviceStatus) {
@@ -369,7 +371,7 @@ private fun DeviceStatusCard(status: DeviceStatus) {
imageVector = Icons.Outlined.CheckCircle,
contentDescription = "Available",
modifier = Modifier.size(22.dp),
- tint = Color(0xFF34C759),
+ tint = AppColors.primaryGreen,
)
} else {
Text(
@@ -444,7 +446,7 @@ private fun RowDivider() {
)
}
-// ── Model Card ──
+// Model Card
@Composable
private fun ModelCard(
@@ -512,13 +514,22 @@ private fun ModelCard(
backgroundColor = model.formatColor.copy(alpha = 0.10f),
)
+ if (model.supportsLora) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Badge(
+ text = "LoRA",
+ textColor = AppColors.primaryPurple,
+ backgroundColor = AppColors.loraBadgeBg,
+ )
+ }
+
Spacer(modifier = Modifier.width(8.dp))
if (model.isDownloaded) {
Badge(
text = "Use",
- textColor = Color(0xFF34C759),
- backgroundColor = Color(0xFF34C759).copy(alpha = 0.10f),
+ textColor = AppColors.primaryGreen,
+ backgroundColor = AppColors.primaryGreen.copy(alpha = 0.10f),
icon = null,
)
} else {
@@ -542,7 +553,7 @@ private fun ModelCard(
}
}
-// ── Badge ──
+// Badge
@Composable
private fun Badge(
@@ -581,9 +592,7 @@ private fun Badge(
}
}
-// ====================
-// SYSTEM TTS ROW
-// ====================
+// System TTS Row
// SystemTTSRow: card-style row matching ModelCard, "Use" action
@Composable
@@ -630,12 +639,12 @@ private fun SystemTTSRow(
imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
modifier = Modifier.size(12.dp),
- tint = Color(0xFF34C759),
+ tint = AppColors.primaryGreen,
)
Text(
text = "Built-in - Always available",
style = MaterialTheme.typography.bodySmall,
- color = Color(0xFF34C759),
+ color = AppColors.primaryGreen,
)
}
}
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
index 0d2fb4dfd..bd2afbe52 100644
--- 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
@@ -1,6 +1,6 @@
package com.runanywhere.runanywhereai.presentation.models
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@@ -57,24 +57,24 @@ class ModelSelectionViewModel(
*/
private fun subscribeToDownloadEvents() {
viewModelScope.launch {
- Log.d(TAG, "📡 Subscribed to download progress events")
+ Timber.d("📡 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%")
+ Timber.d("📊 Download progress: ${event.modelId} - $progressPercent%")
_uiState.update {
it.copy(loadingProgress = "Downloading... $progressPercent%")
}
}
ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> {
- Log.d(TAG, "✅ Download completed: ${event.modelId}")
+ Timber.d("✅ Download completed: ${event.modelId}")
loadModelsAndFrameworks() // Refresh models list
}
ModelEvent.ModelEventType.DOWNLOAD_FAILED -> {
- Log.e(TAG, "❌ Download failed: ${event.modelId} - ${event.error}")
+ Timber.e("❌ Download failed: ${event.modelId} - ${event.error}")
_uiState.update {
it.copy(
isLoadingModel = false,
@@ -103,18 +103,18 @@ class ModelSelectionViewModel(
private fun loadModelsAndFrameworks() {
viewModelScope.launch {
try {
- Log.d(TAG, "🔄 Loading models and frameworks for context: $context")
+ Timber.d("🔄 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")
+ Timber.d("📦 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")
+ Timber.d("📦 Filtered to ${filteredModels.size} models for context $context")
// Extract unique frameworks from filtered models
val relevantFrameworks =
@@ -127,12 +127,12 @@ class ModelSelectionViewModel(
// 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")
+ Timber.d("📱 Added System TTS for TTS context")
}
- Log.d(TAG, "✅ Loaded ${filteredModels.size} models and ${relevantFrameworks.size} frameworks")
+ Timber.d("✅ Loaded ${filteredModels.size} models and ${relevantFrameworks.size} frameworks")
relevantFrameworks.forEach { fw ->
- Log.d(TAG, " Framework: ${fw.displayName}")
+ Timber.d(" Framework: ${fw.displayName}")
}
// Sync with currently loaded model from SDK
@@ -146,7 +146,7 @@ class ModelSelectionViewModel(
}
if (currentLoadedModel != null) {
- Log.d(TAG, "✅ Found currently loaded model for context $context: ${currentLoadedModel.id}")
+ Timber.d("✅ Found currently loaded model for context $context: ${currentLoadedModel.id}")
}
_uiState.update {
@@ -159,7 +159,7 @@ class ModelSelectionViewModel(
)
}
} catch (e: Exception) {
- Log.e(TAG, "❌ Failed to load models: ${e.message}", e)
+ Timber.e(e, "❌ Failed to load models: ${e.message}")
_uiState.update {
it.copy(
isLoading = false,
@@ -222,7 +222,7 @@ class ModelSelectionViewModel(
* Toggle framework expansion
*/
fun toggleFramework(framework: InferenceFramework) {
- Log.d(TAG, "🔀 Toggling framework: ${framework.displayName}")
+ Timber.d("🔀 Toggling framework: ${framework.displayName}")
_uiState.update {
it.copy(
expandedFramework = if (it.expandedFramework == framework) null else framework,
@@ -245,7 +245,7 @@ class ModelSelectionViewModel(
fun startDownload(modelId: String) {
viewModelScope.launch {
try {
- Log.d(TAG, "⬇️ Starting download for model: $modelId")
+ Timber.d("⬇️ Starting download for model: $modelId")
_uiState.update {
it.copy(
@@ -258,7 +258,7 @@ class ModelSelectionViewModel(
// Call SDK download API - it returns a Flow
RunAnywhere.downloadModel(modelId)
.catch { e ->
- Log.e(TAG, "❌ Download stream error: ${e.message}")
+ Timber.e("❌ Download stream error: ${e.message}")
_uiState.update {
it.copy(
isLoadingModel = false,
@@ -270,13 +270,13 @@ class ModelSelectionViewModel(
}
.collect { progress ->
val percent = (progress.progress * 100).toInt()
- Log.d(TAG, "📥 Download progress: $percent%")
+ Timber.d("📥 Download progress: $percent%")
_uiState.update {
it.copy(loadingProgress = "Downloading... $percent%")
}
}
- Log.d(TAG, "✅ Download completed for $modelId")
+ Timber.d("✅ Download completed for $modelId")
// Small delay to ensure registry update propagates
delay(500)
@@ -292,7 +292,7 @@ class ModelSelectionViewModel(
)
}
} catch (e: Exception) {
- Log.e(TAG, "❌ Download failed for $modelId: ${e.message}", e)
+ Timber.e(e, "❌ Download failed for $modelId: ${e.message}")
_uiState.update {
it.copy(
isLoadingModel = false,
@@ -315,7 +315,7 @@ class ModelSelectionViewModel(
*/
suspend fun selectModel(modelId: String) {
try {
- Log.d(TAG, "🔄 Loading model into memory: $modelId (context: $context)")
+ Timber.d("🔄 Loading model into memory: $modelId (context: $context)")
_uiState.update {
it.copy(
@@ -347,14 +347,14 @@ class ModelSelectionViewModel(
ModelSelectionContext.RAG_EMBEDDING,
ModelSelectionContext.RAG_LLM -> {
// RAG models are referenced by file path only
- Log.d(TAG, "ℹ️ RAG context: selecting model by reference only (no load): $modelId")
+ Timber.d("ℹ️ RAG context: selecting model by reference only (no load): $modelId")
}
ModelSelectionContext.VLM -> {
RunAnywhere.loadVLMModel(modelId)
}
}
- Log.d(TAG, "✅ Model selected successfully: $modelId")
+ Timber.d("✅ Model selected successfully: $modelId")
// Get the loaded model
val loadedModel = _uiState.value.models.find { it.id == modelId }
@@ -368,7 +368,7 @@ class ModelSelectionViewModel(
)
}
} catch (e: Exception) {
- Log.e(TAG, "❌ Failed to load model $modelId: ${e.message}", e)
+ Timber.e(e, "❌ Failed to load model $modelId: ${e.message}")
_uiState.update {
it.copy(
isLoadingModel = false,
@@ -410,9 +410,7 @@ class ModelSelectionViewModel(
}
}
- companion object {
- private const val TAG = "ModelSelectionVM"
- }
+ companion object
}
/**
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
index 3dd7a95d9..48a65a152 100644
--- 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
@@ -1,10 +1,26 @@
package com.runanywhere.runanywhereai.presentation.navigation
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
@@ -16,6 +32,9 @@ import com.runanywhere.runanywhereai.presentation.benchmarks.views.BenchmarkDeta
import com.runanywhere.runanywhereai.presentation.chat.ChatScreen
import com.runanywhere.runanywhereai.presentation.components.AppBottomNavigationBar
import com.runanywhere.runanywhereai.presentation.components.BottomNavTab
+import com.runanywhere.runanywhereai.presentation.components.LocalTopBarState
+import com.runanywhere.runanywhereai.presentation.components.TopBarState
+import com.runanywhere.runanywhereai.presentation.lora.LoraManagerScreen
import com.runanywhere.runanywhereai.presentation.rag.DocumentRAGScreen
import com.runanywhere.runanywhereai.presentation.settings.SettingsScreen
import com.runanywhere.runanywhereai.presentation.stt.SpeechToTextScreen
@@ -24,6 +43,9 @@ import com.runanywhere.runanywhereai.presentation.vision.VLMScreen
import com.runanywhere.runanywhereai.presentation.vision.VisionHubScreen
import com.runanywhere.runanywhereai.presentation.voice.VoiceAssistantScreen
+private const val TRANSITION_DURATION = 300
+private const val SLIDE_OFFSET_FRACTION = 4 // 1/4 of width
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppNavigation() {
@@ -31,97 +53,169 @@ fun AppNavigation() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val selectedTab = routeToBottomNavTab(currentDestination?.route)
+ val topBarState = remember { TopBarState() }
- Scaffold(
- bottomBar = {
- AppBottomNavigationBar(
- selectedTab = selectedTab,
- onTabSelected = { tab ->
- val route = bottomNavTabToRoute(tab)
- navController.navigate(route) {
- popUpTo(navController.graph.findStartDestination().id) {
- saveState = true
+ CompositionLocalProvider(LocalTopBarState provides topBarState) {
+ Scaffold(
+ topBar = {
+ val custom = topBarState.customTopBar
+ if (custom != null) {
+ custom()
+ } else {
+ TopAppBar(
+ title = { Text(topBarState.title) },
+ navigationIcon = {
+ if (topBarState.showBack) {
+ IconButton(onClick = { topBarState.onBack?.invoke() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ }
+ },
+ actions = topBarState.actions,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ }
+ },
+ bottomBar = {
+ AppBottomNavigationBar(
+ selectedTab = selectedTab,
+ onTabSelected = { tab ->
+ val route = bottomNavTabToRoute(tab)
+ navController.navigate(route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
}
- launchSingleTop = true
- restoreState = true
- }
- },
- )
- },
- ) { paddingValues ->
- NavHost(
- navController = navController,
- startDestination = NavigationRoute.CHAT,
- modifier = Modifier.padding(paddingValues),
- ) {
- composable(NavigationRoute.CHAT) {
- ChatScreen()
- }
-
- composable(NavigationRoute.VISION) {
- VisionHubScreen(
- onNavigateToVLM = {
- navController.navigate(NavigationRoute.VLM)
- },
- onNavigateToImageGeneration = {
- // Future
},
)
- }
+ },
+ ) { paddingValues ->
+ NavHost(
+ navController = navController,
+ startDestination = NavigationRoute.CHAT,
+ modifier = Modifier.padding(paddingValues),
+ enterTransition = {
+ slideInHorizontally(
+ initialOffsetX = { it / SLIDE_OFFSET_FRACTION },
+ animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing),
+ ) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
+ },
+ exitTransition = {
+ slideOutHorizontally(
+ targetOffsetX = { -it / SLIDE_OFFSET_FRACTION },
+ animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing),
+ ) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
+ },
+ popEnterTransition = {
+ slideInHorizontally(
+ initialOffsetX = { -it / SLIDE_OFFSET_FRACTION },
+ animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing),
+ ) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
+ },
+ popExitTransition = {
+ slideOutHorizontally(
+ targetOffsetX = { it / SLIDE_OFFSET_FRACTION },
+ animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing),
+ ) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
+ },
+ ) {
+ composable(NavigationRoute.CHAT) {
+ ChatScreen()
+ }
- composable(NavigationRoute.VLM) {
- VLMScreen()
- }
+ composable(NavigationRoute.VISION) {
+ VisionHubScreen(
+ onNavigateToVLM = {
+ navController.navigate(NavigationRoute.VLM)
+ },
+ onNavigateToImageGeneration = {
+ // Future
+ },
+ )
+ }
- composable(NavigationRoute.VOICE) {
- VoiceAssistantScreen()
- }
+ composable(NavigationRoute.VLM) {
+ VLMScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
- // "More" hub routes — STT, TTS, RAG, and Benchmarks here to match iOS structure
- composable(NavigationRoute.MORE) {
- MoreHubScreen(
- onNavigateToSTT = {
- navController.navigate(NavigationRoute.STT)
- },
- onNavigateToTTS = {
- navController.navigate(NavigationRoute.TTS)
- },
- onNavigateToRAG = {
- navController.navigate(NavigationRoute.RAG)
- },
- onNavigateToBenchmarks = {
- navController.navigate(NavigationRoute.BENCHMARKS)
- },
- )
- }
+ composable(NavigationRoute.VOICE) {
+ VoiceAssistantScreen()
+ }
- composable(NavigationRoute.STT) {
- SpeechToTextScreen()
- }
+ // "More" hub routes
+ composable(NavigationRoute.MORE) {
+ MoreHubScreen(
+ onNavigateToSTT = {
+ navController.navigate(NavigationRoute.STT)
+ },
+ onNavigateToTTS = {
+ navController.navigate(NavigationRoute.TTS)
+ },
+ onNavigateToRAG = {
+ navController.navigate(NavigationRoute.RAG)
+ },
+ onNavigateToBenchmarks = {
+ navController.navigate(NavigationRoute.BENCHMARKS)
+ },
+ onNavigateToLoraManager = {
+ navController.navigate(NavigationRoute.LORA_MANAGER)
+ },
+ )
+ }
- composable(NavigationRoute.TTS) {
- TextToSpeechScreen()
- }
+ composable(NavigationRoute.STT) {
+ SpeechToTextScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
- composable(NavigationRoute.RAG) {
- DocumentRAGScreen()
- }
+ composable(NavigationRoute.TTS) {
+ TextToSpeechScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
- composable(NavigationRoute.BENCHMARKS) {
- BenchmarkDashboardScreen(
- onNavigateToDetail = { runId ->
- navController.navigate("${NavigationRoute.BENCHMARK_DETAIL}/$runId")
- },
- )
- }
+ composable(NavigationRoute.RAG) {
+ DocumentRAGScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
- composable("${NavigationRoute.BENCHMARK_DETAIL}/{runId}") { backStackEntry ->
- val runId = backStackEntry.arguments?.getString("runId") ?: return@composable
- BenchmarkDetailScreen(runId = runId)
- }
+ composable(NavigationRoute.BENCHMARKS) {
+ BenchmarkDashboardScreen(
+ onNavigateToDetail = { runId ->
+ navController.navigate("${NavigationRoute.BENCHMARK_DETAIL}/$runId")
+ },
+ onBack = { navController.popBackStack() },
+ )
+ }
- composable(NavigationRoute.SETTINGS) {
- SettingsScreen()
+ composable("${NavigationRoute.BENCHMARK_DETAIL}/{runId}") { backStackEntry ->
+ val runId = backStackEntry.arguments?.getString("runId") ?: return@composable
+ BenchmarkDetailScreen(
+ runId = runId,
+ onBack = { navController.popBackStack() },
+ )
+ }
+
+ composable(NavigationRoute.LORA_MANAGER) {
+ LoraManagerScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
+
+ composable(NavigationRoute.SETTINGS) {
+ SettingsScreen()
+ }
}
}
}
@@ -142,6 +236,7 @@ private fun routeToBottomNavTab(route: String?): BottomNavTab {
NavigationRoute.TTS,
NavigationRoute.RAG,
NavigationRoute.BENCHMARKS,
+ NavigationRoute.LORA_MANAGER,
) || route.startsWith(NavigationRoute.BENCHMARK_DETAIL) -> BottomNavTab.More
route == NavigationRoute.SETTINGS -> BottomNavTab.Settings
else -> BottomNavTab.Chat
@@ -169,5 +264,6 @@ object NavigationRoute {
const val RAG = "rag"
const val BENCHMARKS = "benchmarks"
const val BENCHMARK_DETAIL = "benchmark_detail"
+ const val LORA_MANAGER = "lora_manager"
const val SETTINGS = "settings"
-}
\ No newline at end of file
+}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/MoreHubScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/MoreHubScreen.kt
index ef9d736ab..c1baa0f45 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/MoreHubScreen.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/navigation/MoreHubScreen.kt
@@ -16,127 +16,131 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Speed
-import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
+import com.runanywhere.runanywhereai.ui.theme.AppColors
+import com.runanywhere.runanywhereai.ui.theme.Dimensions
/**
* More Hub Screen — matches iOS MoreHubView.
* Contains additional utility features: STT, TTS, RAG.
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoreHubScreen(
onNavigateToSTT: () -> Unit,
onNavigateToTTS: () -> Unit,
onNavigateToRAG: () -> Unit,
onNavigateToBenchmarks: () -> Unit,
+ onNavigateToLoraManager: () -> Unit = {},
) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("More") },
- )
- },
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp, vertical = 8.dp),
- ) {
- // Section header
+ ConfigureTopBar(title = "More")
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = Dimensions.large, vertical = Dimensions.smallMedium),
+ ) {
+ // Audio AI section
Text(
"Audio AI",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp, bottom = 8.dp),
+ modifier = Modifier.padding(start = Dimensions.xSmall, bottom = Dimensions.smallMedium),
)
- // Speech to Text
MoreFeatureCard(
icon = Icons.Filled.GraphicEq,
- iconColor = Color(0xFF2196F3), // Blue
+ iconColor = AppColors.featureBlue,
title = "Speech to Text",
subtitle = "Transcribe audio to text using on-device models",
onClick = onNavigateToSTT,
)
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
- // Text to Speech
MoreFeatureCard(
- icon = Icons.Filled.VolumeUp,
- iconColor = Color(0xFF4CAF50), // Green
+ icon = Icons.AutoMirrored.Filled.VolumeUp,
+ iconColor = AppColors.featureGreen,
title = "Text to Speech",
subtitle = "Convert text to natural-sounding speech",
onClick = onNavigateToTTS,
)
- Spacer(modifier = Modifier.height(24.dp))
-
- // =========================
- // Document Section (RAG)
- // =========================
+ Spacer(modifier = Modifier.height(Dimensions.xxLarge))
+ // Document AI section
Text(
"Document AI",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp, bottom = 8.dp),
+ modifier = Modifier.padding(start = Dimensions.xSmall, bottom = Dimensions.smallMedium),
)
MoreFeatureCard(
icon = Icons.Filled.Description,
- iconColor = Color(0xFF673AB7), // Purple
+ iconColor = AppColors.featureDeepPurple,
title = "Document Q&A",
subtitle = "Ask questions about your documents using on-device AI",
onClick = onNavigateToRAG,
)
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(Dimensions.xxLarge))
+
+ // Model Customization section
+ Text(
+ "Model Customization",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = Dimensions.xSmall, bottom = Dimensions.smallMedium),
+ )
+
+ MoreFeatureCard(
+ icon = Icons.Filled.Tune,
+ iconColor = AppColors.primaryPurple,
+ title = "LoRA Adapters",
+ subtitle = "Manage and apply LoRA fine-tuning adapters to models",
+ onClick = onNavigateToLoraManager,
+ )
+
+ Spacer(modifier = Modifier.height(Dimensions.xxLarge))
// Performance section
Text(
"Performance",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp, bottom = 8.dp),
+ modifier = Modifier.padding(start = Dimensions.xSmall, bottom = Dimensions.smallMedium),
)
- // Benchmarks
MoreFeatureCard(
icon = Icons.Filled.Speed,
- iconColor = Color(0xFFFF5500), // Brand orange
+ iconColor = AppColors.primaryAccent,
title = "Benchmarks",
subtitle = "Measure on-device AI performance across models",
onClick = onNavigateToBenchmarks,
)
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(Dimensions.large))
// Footer
Text(
"Additional AI utilities and tools",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp),
+ modifier = Modifier.padding(start = Dimensions.xSmall),
)
- }
}
}
@@ -152,7 +156,7 @@ private fun MoreFeatureCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
- shape = RoundedCornerShape(12.dp),
+ shape = RoundedCornerShape(Dimensions.cornerRadiusXLarge),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
@@ -160,7 +164,7 @@ private fun MoreFeatureCard(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp),
+ .padding(Dimensions.large),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -169,16 +173,15 @@ private fun MoreFeatureCard(
tint = iconColor,
modifier = Modifier.size(32.dp),
)
- Spacer(modifier = Modifier.width(16.dp))
+ Spacer(modifier = Modifier.width(Dimensions.large))
Column(modifier = Modifier.weight(1f)) {
Text(
title,
- fontWeight = FontWeight.Medium,
- fontSize = 16.sp,
+ style = MaterialTheme.typography.bodyLarge,
)
Text(
subtitle,
- fontSize = 13.sp,
+ style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -189,4 +192,4 @@ private fun MoreFeatureCard(
)
}
}
-}
\ No newline at end of file
+}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/DocumentRAGScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/DocumentRAGScreen.kt
index 0b83bcf57..09e540499 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/DocumentRAGScreen.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/DocumentRAGScreen.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.Chat
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
@@ -24,6 +25,7 @@ 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.presentation.components.ConfigureTopBar
import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.ui.theme.Dimensions
@@ -42,9 +44,11 @@ import java.io.File
* 4. Messages Area — LazyColumn with user/assistant bubbles
* 5. Input Bar — question field + send button
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun DocumentRAGScreen(viewModel: RAGViewModel = viewModel()) {
+fun DocumentRAGScreen(
+ onBack: () -> Unit = {},
+ viewModel: RAGViewModel = viewModel(),
+) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -79,22 +83,13 @@ fun DocumentRAGScreen(viewModel: RAGViewModel = viewModel()) {
}
}
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Document Q&A") },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.surface,
- ),
- )
- },
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .background(AppColors.backgroundGrouped),
- ) {
+ ConfigureTopBar(title = "Document Q&A", showBack = true, onBack = onBack)
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(AppColors.backgroundGrouped),
+ ) {
// 1. Model Setup Section
ModelSetupSection(
selectedEmbeddingModel = selectedEmbeddingModel,
@@ -138,7 +133,6 @@ fun DocumentRAGScreen(viewModel: RAGViewModel = viewModel()) {
onQuestionChange = viewModel::updateQuestion,
onSend = viewModel::askQuestion,
)
- }
}
// Embedding model picker sheet
@@ -197,7 +191,7 @@ private fun ModelSetupSection(
)
ModelPickerRow(
label = "LLM Model",
- icon = { Icon(Icons.Outlined.Chat, contentDescription = null, modifier = Modifier.size(20.dp), tint = AppColors.textSecondary) },
+ icon = { Icon(Icons.AutoMirrored.Outlined.Chat, contentDescription = null, modifier = Modifier.size(20.dp), tint = AppColors.textSecondary) },
selectedModel = selectedLLMModel,
onClick = onLLMPickerTap,
)
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/RAGViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/RAGViewModel.kt
index bff626deb..aa254a6da 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/RAGViewModel.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/rag/RAGViewModel.kt
@@ -2,7 +2,7 @@ package com.runanywhere.runanywhereai.presentation.rag
import android.content.Context
import android.net.Uri
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runanywhere.runanywhereai.domain.services.DocumentService
@@ -127,15 +127,15 @@ class RAGViewModel : ViewModel() {
try {
val fileName = DocumentService.getFileName(context, uri) ?: "Document"
- Log.i(TAG, "Extracting text from document: $fileName")
+ Timber.i("Extracting text from document: $fileName")
val extractedText = withContext(Dispatchers.IO) {
DocumentService.extractText(context, uri)
}
- Log.i(TAG, "Creating RAG pipeline")
+ Timber.i("Creating RAG pipeline")
RunAnywhere.ragCreatePipeline(config)
- Log.i(TAG, "Ingesting document text (${extractedText.length} chars)")
+ Timber.i("Ingesting document text (${extractedText.length} chars)")
RunAnywhere.ragIngest(text = extractedText)
_uiState.update {
@@ -144,12 +144,12 @@ class RAGViewModel : ViewModel() {
isDocumentLoaded = true,
)
}
- Log.i(TAG, "Document loaded successfully: $fileName")
+ Timber.i("Document loaded successfully: $fileName")
} catch (e: DocumentServiceError) {
- Log.e(TAG, "Document extraction failed: ${e.message}")
+ Timber.e("Document extraction failed: ${e.message}")
_uiState.update { it.copy(error = e.message) }
} catch (e: Exception) {
- Log.e(TAG, "Failed to load document: ${e.message}", e)
+ Timber.e(e, "Failed to load document: ${e.message}")
_uiState.update { it.copy(error = e.message ?: "Failed to load document") }
} finally {
_uiState.update { it.copy(isLoadingDocument = false) }
@@ -181,7 +181,7 @@ class RAGViewModel : ViewModel() {
}
try {
- Log.i(TAG, "Querying RAG pipeline: $question")
+ Timber.i("Querying RAG pipeline: $question")
val result = RunAnywhere.ragQuery(question = question)
val answerWithTiming = "${result.answer}\n\nAnswer generated in ${
@@ -196,9 +196,9 @@ class RAGViewModel : ViewModel() {
),
)
}
- Log.i(TAG, "Query complete (${result.totalTimeMs}ms)")
+ Timber.i("Query complete (${result.totalTimeMs}ms)")
} catch (e: Exception) {
- Log.e(TAG, "Query failed: ${e.message}", e)
+ Timber.e(e, "Query failed: ${e.message}")
val errorText = "Error: ${e.message ?: "Query failed"}"
_uiState.update {
it.copy(
@@ -225,9 +225,9 @@ class RAGViewModel : ViewModel() {
viewModelScope.launch {
try {
RunAnywhere.ragDestroyPipeline()
- Log.i(TAG, "Document cleared and pipeline destroyed")
+ Timber.i("Document cleared and pipeline destroyed")
} catch (e: Exception) {
- Log.e(TAG, "Failed to destroy pipeline: ${e.message}", e)
+ Timber.e(e, "Failed to destroy pipeline: ${e.message}")
} finally {
_uiState.update {
RAGUiState() // Reset to initial state
@@ -235,8 +235,4 @@ class RAGViewModel : ViewModel() {
}
}
}
-
- companion object {
- private const val TAG = "RAGViewModel"
- }
}
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
index a17d0dc89..1aa607660 100644
--- 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
@@ -25,13 +25,13 @@ import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.VpnKey
-import androidx.compose.material.icons.filled.MenuBook
+import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material.icons.filled.OpenInNew
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.RestartAlt
@@ -53,6 +53,7 @@ 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.presentation.components.ConfigureTopBar
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.ui.theme.AppTypography
import com.runanywhere.runanywhereai.ui.theme.Dimensions
@@ -76,6 +77,8 @@ fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) {
viewModel.refreshStorage()
}
+ ConfigureTopBar(title = "Settings")
+
Column(
modifier =
Modifier
@@ -83,28 +86,6 @@ fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) {
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState()),
) {
- // Header
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = Dimensions.padding16, vertical = Dimensions.padding20),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(Dimensions.padding16),
- ) {
- Icon(
- imageVector = Icons.Filled.Settings,
- contentDescription = null,
- tint = AppColors.primaryOrange,
- modifier = Modifier.size(22.dp),
- )
- Text(
- text = "Settings",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground,
- )
- }
-
// 1. API Configuration (Testing)
SettingsSection(title = "API Configuration (Testing)", icon = null) {
Row(
@@ -405,7 +386,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) {
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
- Icons.Filled.MenuBook,
+ Icons.AutoMirrored.Filled.MenuBook,
contentDescription = null,
tint = AppColors.primaryOrange,
modifier = Modifier.size(22.dp),
@@ -417,7 +398,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = viewModel()) {
)
Spacer(modifier = Modifier.weight(1f))
Icon(
- Icons.Filled.OpenInNew,
+ Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Open link",
modifier = Modifier.size(22.dp),
tint = AppColors.primaryOrange,
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
index 55f3881fe..95ab806b0 100644
--- 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
@@ -2,7 +2,7 @@ package com.runanywhere.runanywhereai.presentation.settings
import android.app.Application
import android.content.Context
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences
@@ -94,7 +94,6 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
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"
@@ -126,7 +125,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
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)
+ Timber.e(e, "Failed to get stored API key")
null
}
}
@@ -158,7 +157,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
"https://$trimmed"
}
} catch (e: Exception) {
- Log.e(TAG, "Failed to get stored base URL", e)
+ Timber.e(e, "Failed to get stored base URL")
null
}
}
@@ -224,11 +223,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.collect { event ->
when (event.eventType) {
ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> {
- Log.d(TAG, "📥 Model download completed: ${event.modelId}, refreshing storage...")
+ Timber.d("📥 Model download completed: ${event.modelId}, refreshing storage...")
loadStorageData()
}
ModelEvent.ModelEventType.DELETED -> {
- Log.d(TAG, "🗑️ Model deleted: ${event.modelId}, refreshing storage...")
+ Timber.d("🗑️ Model deleted: ${event.modelId}, refreshing storage...")
loadStorageData()
}
else -> {
@@ -247,7 +246,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(isLoading = true) }
try {
- Log.d(TAG, "Loading storage info via storageInfo()...")
+ Timber.d("Loading storage info via storageInfo()...")
// Use SDK's storageInfo()
val storageInfo = RunAnywhere.storageInfo()
@@ -262,11 +261,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
}
- 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}")
+ Timber.d("Storage info received:")
+ Timber.d(" - Total space: ${storageInfo.deviceStorage.totalSpace}")
+ Timber.d(" - Free space: ${storageInfo.deviceStorage.freeSpace}")
+ Timber.d(" - Model storage size: ${storageInfo.totalModelsSize}")
+ Timber.d(" - Stored models count: ${storedModels.size}")
_uiState.update {
it.copy(
@@ -278,9 +277,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
}
- Log.d(TAG, "Storage data loaded successfully")
+ Timber.d("Storage data loaded successfully")
} catch (e: Exception) {
- Log.e(TAG, "Failed to load storage data", e)
+ Timber.e(e, "Failed to load storage data")
_uiState.update {
it.copy(
isLoading = false,
@@ -304,15 +303,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun deleteModelById(modelId: String) {
viewModelScope.launch {
try {
- Log.d(TAG, "Deleting model: $modelId")
+ Timber.d("Deleting model: $modelId")
// Use SDK's deleteModel extension function
RunAnywhere.deleteModel(modelId)
- Log.d(TAG, "Model deleted successfully: $modelId")
+ Timber.d("Model deleted successfully: $modelId")
// Refresh storage data after deletion
loadStorageData()
} catch (e: Exception) {
- Log.e(TAG, "Failed to delete model: $modelId", e)
+ Timber.e(e, "Failed to delete model: $modelId")
_uiState.update {
it.copy(errorMessage = "Failed to delete model: ${e.message}")
}
@@ -326,14 +325,14 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun clearCache() {
viewModelScope.launch {
try {
- Log.d(TAG, "Clearing cache via clearCache()...")
+ Timber.d("Clearing cache via clearCache()...")
RunAnywhere.clearCache()
- Log.d(TAG, "Cache cleared successfully")
+ Timber.d("Cache cleared successfully")
// Refresh storage data after clearing cache
loadStorageData()
} catch (e: Exception) {
- Log.e(TAG, "Failed to clear cache", e)
+ Timber.e(e, "Failed to clear cache")
_uiState.update {
it.copy(errorMessage = "Failed to clear cache: ${e.message}")
}
@@ -347,15 +346,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun cleanTempFiles() {
viewModelScope.launch {
try {
- Log.d(TAG, "Cleaning temp files (via clearing cache)...")
+ Timber.d("Cleaning temp files (via clearing cache)...")
// Clean temp files by clearing cache
RunAnywhere.clearCache()
- Log.d(TAG, "Temp files cleaned successfully")
+ Timber.d("Temp files cleaned successfully")
// Refresh storage data after cleaning
loadStorageData()
} catch (e: Exception) {
- Log.e(TAG, "Failed to clean temp files", e)
+ Timber.e(e, "Failed to clean temp files")
_uiState.update {
it.copy(errorMessage = "Failed to clean temporary files: ${e.message}")
}
@@ -381,9 +380,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
systemPrompt = systemPrompt
)
}
- Log.d(TAG, "Generation settings loaded - temperature: $temperature, maxTokens: $maxTokens, systemPrompt length: ${systemPrompt.length}")
+ Timber.d("Generation settings loaded - temperature: $temperature, maxTokens: $maxTokens, systemPrompt length: ${systemPrompt.length}")
} catch (e: Exception) {
- Log.e(TAG, "Failed to load generation settings", e)
+ Timber.e(e, "Failed to load generation settings")
}
}
@@ -421,9 +420,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.putString(KEY_SYSTEM_PROMPT, currentState.systemPrompt)
.apply()
- Log.d(TAG, "Generation settings saved successfully - temperature: ${currentState.temperature}, maxTokens: ${currentState.maxTokens}")
+ Timber.d("Generation settings saved successfully - temperature: ${currentState.temperature}, maxTokens: ${currentState.maxTokens}")
} catch (e: Exception) {
- Log.e(TAG, "Failed to save generation settings", e)
+ Timber.e(e, "Failed to save generation settings")
_uiState.update {
it.copy(errorMessage = "Failed to save generation settings: ${e.message}")
}
@@ -449,9 +448,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
isBaseURLConfigured = storedBaseURL.isNotEmpty()
)
}
- Log.d(TAG, "API configuration loaded - apiKey configured: ${storedApiKey.isNotEmpty()}, baseURL configured: ${storedBaseURL.isNotEmpty()}")
+ Timber.d("API configuration loaded - apiKey configured: ${storedApiKey.isNotEmpty()}, baseURL configured: ${storedBaseURL.isNotEmpty()}")
} catch (e: Exception) {
- Log.e(TAG, "Failed to load API configuration", e)
+ Timber.e(e, "Failed to load API configuration")
}
}
@@ -508,9 +507,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
}
- Log.d(TAG, "API configuration saved successfully")
+ Timber.d("API configuration saved successfully")
} catch (e: Exception) {
- Log.e(TAG, "Failed to save API configuration", e)
+ Timber.e(e, "Failed to save API configuration")
_uiState.update {
it.copy(errorMessage = "Failed to save API configuration: ${e.message}")
}
@@ -542,9 +541,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
}
- Log.d(TAG, "API configuration cleared successfully")
+ Timber.d("API configuration cleared successfully")
} catch (e: Exception) {
- Log.e(TAG, "Failed to clear API configuration", e)
+ Timber.e(e, "Failed to clear API configuration")
_uiState.update {
it.copy(errorMessage = "Failed to clear API configuration: ${e.message}")
}
@@ -561,7 +560,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
.edit()
.remove(KEY_DEVICE_REGISTERED)
.apply()
- Log.d(TAG, "Device registration cleared - will re-register on next launch")
+ Timber.d("Device registration cleared - will re-register on next launch")
}
/**
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/ToolSettingsViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/ToolSettingsViewModel.kt
index ff97fafec..2b350cd51 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/ToolSettingsViewModel.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/settings/ToolSettingsViewModel.kt
@@ -2,7 +2,7 @@ package com.runanywhere.runanywhereai.presentation.settings
import android.app.Application
import android.content.Context
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.runanywhere.sdk.public.extensions.LLM.ToolDefinition
@@ -54,7 +54,6 @@ class ToolSettingsViewModel private constructor(application: Application) : Andr
get() = _uiState.value.toolCallingEnabled
companion object {
- private const val TAG = "ToolSettingsVM"
private const val PREFS_NAME = "tool_settings"
private const val KEY_TOOL_CALLING_ENABLED = "tool_calling_enabled"
@@ -173,11 +172,11 @@ class ToolSettingsViewModel private constructor(application: Application) : Andr
}
)
- Log.i(TAG, "✅ Demo tools registered")
+ Timber.i("✅ Demo tools registered")
refreshRegisteredTools()
} catch (e: Exception) {
- Log.e(TAG, "Failed to register demo tools", e)
+ Timber.e(e, "Failed to register demo tools")
} finally {
_uiState.update { it.copy(isLoading = false) }
}
@@ -190,9 +189,9 @@ class ToolSettingsViewModel private constructor(application: Application) : Andr
try {
RunAnywhereToolCalling.clearTools()
refreshRegisteredTools()
- Log.i(TAG, "✅ All tools cleared")
+ Timber.i("✅ All tools cleared")
} catch (e: Exception) {
- Log.e(TAG, "Failed to clear tools", e)
+ Timber.e(e, "Failed to clear tools")
} finally {
_uiState.update { it.copy(isLoading = false) }
}
@@ -212,9 +211,7 @@ class ToolSettingsViewModel private constructor(application: Application) : Andr
}
}
- // ========================================================================
// Tool Executor Implementations
- // ========================================================================
/**
* Fetch weather using Open-Meteo API (free, no API key required)
@@ -285,13 +282,13 @@ class ToolSettingsViewModel private constructor(application: Application) : Andr
)
}
} catch (e: TimeoutCancellationException) {
- Log.w(TAG, "Weather API request timed out for location: $location")
+ Timber.w("Weather API request timed out for location: $location")
mapOf(
"error" to ToolValue.StringValue("Weather API request timed out. Please try again."),
"location" to ToolValue.StringValue(location)
)
} catch (e: Exception) {
- Log.e(TAG, "Weather fetch failed", e)
+ Timber.e(e, "Weather fetch failed")
mapOf(
"error" to ToolValue.StringValue("Failed to fetch weather: ${e.message}"),
"location" to ToolValue.StringValue(location)
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
index 6cc8c172c..f0ca7637d 100644
--- 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
@@ -47,6 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.runanywhere.runanywhereai.presentation.chat.components.ModelLoadedToast
import com.runanywhere.runanywhereai.presentation.chat.components.ModelRequiredOverlay
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.util.getModelLogoResIdForName
@@ -64,9 +65,11 @@ import kotlinx.coroutines.launch
* - Model status banner
* - Transcription display
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SpeechToTextScreen(viewModel: SpeechToTextViewModel = viewModel()) {
+fun SpeechToTextScreen(
+ onBack: () -> Unit = {},
+ viewModel: SpeechToTextViewModel = viewModel(),
+) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showModelPicker by remember { mutableStateOf(false) }
@@ -90,151 +93,138 @@ fun SpeechToTextScreen(viewModel: SpeechToTextViewModel = viewModel()) {
}
}
- Scaffold(
- topBar = {
+ ConfigureTopBar(
+ title = "Speech to Text",
+ showBack = true,
+ onBack = onBack,
+ actions = {
if (uiState.isModelLoaded) {
- TopAppBar(
- title = {
- Text(
- text = "Speech to Text",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- },
- actions = {
- Surface(
- onClick = { showModelPicker = true },
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- ) {
- STTModelChip(
- modelName = uiState.selectedModelName,
- mode = uiState.mode,
- modifier = Modifier.padding(
- start = 6.dp,
- end = 12.dp,
- top = 6.dp,
- bottom = 6.dp,
- ),
- )
- }
- },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.surface,
- ),
- )
+ Surface(
+ onClick = { showModelPicker = true },
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ STTModelChip(
+ modelName = uiState.selectedModelName,
+ mode = uiState.mode,
+ modifier = Modifier.padding(
+ start = 6.dp,
+ end = 12.dp,
+ top = 6.dp,
+ bottom = 6.dp,
+ ),
+ )
+ }
}
},
- ) { 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),
- )
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ if (uiState.isModelLoaded) {
+ STTModeSelector(
+ selectedMode = uiState.mode,
+ supportsLiveMode = uiState.supportsLiveMode,
+ onModeChange = { viewModel.setMode(it) },
+ )
- uiState.errorMessage?.let { error ->
- Text(
- text = error,
- style = MaterialTheme.typography.bodySmall,
- color = AppColors.statusRed,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- textAlign = TextAlign.Center,
- )
- }
+ 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),
+ )
- // Audio level indicator - green bars
- if (uiState.recordingState == RecordingState.RECORDING) {
- AudioLevelIndicator(
- audioLevel = uiState.audioLevel,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- )
- }
+ uiState.errorMessage?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodySmall,
+ color = AppColors.statusRed,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ textAlign = TextAlign.Center,
+ )
+ }
- // Controls section
- ControlsSection(
- recordingState = uiState.recordingState,
+ // Audio level indicator - green bars
+ if (uiState.recordingState == RecordingState.RECORDING) {
+ AudioLevelIndicator(
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)
- }
- },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
- }
- if (!uiState.isModelLoaded && uiState.recordingState != RecordingState.PROCESSING) {
- ModelRequiredOverlay(
- modality = ModelSelectionContext.STT,
- onSelectModel = { showModelPicker = true },
- modifier = Modifier.matchParentSize(),
+ // 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)
+ }
+ },
)
}
+ }
- // Model loaded toast overlay
- ModelLoadedToast(
- modelName = loadedModelToastName,
- isVisible = showModelLoadedToast,
- onDismiss = { showModelLoadedToast = false },
- modifier = Modifier.align(Alignment.TopCenter),
+ if (!uiState.isModelLoaded && uiState.recordingState != RecordingState.PROCESSING) {
+ ModelRequiredOverlay(
+ modality = ModelSelectionContext.STT,
+ 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
- }
- },
- )
- }
+ 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,
+ )
+ // Show model loaded toast
+ loadedModelToastName = model.name
+ showModelLoadedToast = true
+ }
+ },
+ )
+ }
}
/**
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
index d2405cb78..eca7537f2 100644
--- 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
@@ -3,7 +3,7 @@ package com.runanywhere.runanywhereai.presentation.stt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
-import android.util.Log
+import timber.log.Timber
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -101,7 +101,6 @@ data class STTUiState(
*/
class SpeechToTextViewModel : ViewModel() {
companion object {
- private const val TAG = "STTViewModel"
private const val SAMPLE_RATE = 16000 // 16kHz for Whisper/ONNX STT models
}
@@ -123,7 +122,7 @@ class SpeechToTextViewModel : ViewModel() {
private var hasSubscribedToEvents = false
init {
- Log.d(TAG, "STTViewModel initialized")
+ Timber.d("STTViewModel initialized")
}
/**
@@ -132,13 +131,13 @@ class SpeechToTextViewModel : ViewModel() {
*/
fun initialize(context: Context) {
if (isInitialized) {
- Log.d(TAG, "STT view model already initialized, skipping")
+ Timber.d("STT view model already initialized, skipping")
return
}
isInitialized = true
viewModelScope.launch {
- Log.i(TAG, "Initializing STT view model...")
+ Timber.i("Initializing STT view model...")
// Initialize audio capture service
audioCaptureService = AudioCaptureService(context)
@@ -151,7 +150,7 @@ class SpeechToTextViewModel : ViewModel() {
) == PackageManager.PERMISSION_GRANTED
if (!hasPermission) {
- Log.w(TAG, "Microphone permission not granted")
+ Timber.w("Microphone permission not granted")
_uiState.update { it.copy(errorMessage = "Microphone permission required") }
}
@@ -169,7 +168,7 @@ class SpeechToTextViewModel : ViewModel() {
*/
private fun subscribeToSDKEvents() {
if (hasSubscribedToEvents) {
- Log.d(TAG, "Already subscribed to SDK events, skipping")
+ Timber.d("Already subscribed to SDK events, skipping")
return
}
hasSubscribedToEvents = true
@@ -193,7 +192,7 @@ class SpeechToTextViewModel : ViewModel() {
private fun handleModelEvent(event: ModelEvent) {
when (event.eventType) {
ModelEvent.ModelEventType.LOADED -> {
- Log.i(TAG, "STT model loaded: ${event.modelId}")
+ Timber.i("STT model loaded: ${event.modelId}")
_uiState.update {
it.copy(
isModelLoaded = true,
@@ -204,7 +203,7 @@ class SpeechToTextViewModel : ViewModel() {
}
}
ModelEvent.ModelEventType.UNLOADED -> {
- Log.i(TAG, "STT model unloaded: ${event.modelId}")
+ Timber.i("STT model unloaded: ${event.modelId}")
_uiState.update {
it.copy(
isModelLoaded = false,
@@ -215,15 +214,15 @@ class SpeechToTextViewModel : ViewModel() {
}
}
ModelEvent.ModelEventType.DOWNLOAD_STARTED -> {
- Log.i(TAG, "STT model download started: ${event.modelId}")
+ Timber.i("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}")
+ Timber.i("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}")
+ Timber.e("STT model download failed: ${event.modelId} - ${event.error}")
_uiState.update {
it.copy(
errorMessage = "Download failed: ${event.error}",
@@ -252,7 +251,7 @@ class SpeechToTextViewModel : ViewModel() {
selectedModelName = displayName,
)
}
- Log.i(TAG, "STT model already loaded: $displayName")
+ Timber.i("STT model already loaded: $displayName")
}
}
@@ -280,7 +279,7 @@ class SpeechToTextViewModel : ViewModel() {
modelId: String,
framework: InferenceFramework?,
) {
- Log.i(TAG, "Model loaded notification: $modelName (id: $modelId, framework: ${framework?.displayName})")
+ Timber.i("Model loaded notification: $modelName (id: $modelId, framework: ${framework?.displayName})")
_uiState.update {
it.copy(
isModelLoaded = true,
@@ -313,7 +312,7 @@ class SpeechToTextViewModel : ViewModel() {
}
try {
- Log.i(TAG, "Loading STT model: $modelName (id: $modelId)")
+ Timber.i("Loading STT model: $modelName (id: $modelId)")
// Use SDK's loadSTTModel extension function
RunAnywhere.loadSTTModel(modelId)
@@ -327,9 +326,9 @@ class SpeechToTextViewModel : ViewModel() {
)
}
- Log.i(TAG, "✅ STT model loaded successfully: $modelName")
+ Timber.i("✅ STT model loaded successfully: $modelName")
} catch (e: Exception) {
- Log.e(TAG, "Failed to load STT model: ${e.message}", e)
+ Timber.e(e, "Failed to load STT model: ${e.message}")
_uiState.update {
it.copy(
errorMessage = "Failed to load model: ${e.message}",
@@ -359,7 +358,7 @@ class SpeechToTextViewModel : ViewModel() {
* iOS Reference: startRecording() in STTViewModel.swift
*/
private suspend fun startRecording() {
- Log.i(TAG, "Starting recording in ${_uiState.value.mode} mode")
+ Timber.i("Starting recording in ${_uiState.value.mode} mode")
if (!_uiState.value.isModelLoaded) {
_uiState.update { it.copy(errorMessage = "No STT model loaded") }
@@ -409,9 +408,9 @@ class SpeechToTextViewModel : ViewModel() {
_uiState.update { it.copy(audioLevel = normalizedLevel) }
}
} catch (e: kotlinx.coroutines.CancellationException) {
- Log.d(TAG, "Batch recording cancelled (expected when stopping)")
+ Timber.d("Batch recording cancelled (expected when stopping)")
} catch (e: Exception) {
- Log.e(TAG, "Error during batch recording: ${e.message}", e)
+ Timber.e(e, "Error during batch recording: ${e.message}")
_uiState.update {
it.copy(
errorMessage = "Recording error: ${e.message}",
@@ -475,15 +474,15 @@ class SpeechToTextViewModel : ViewModel() {
handleSTTStreamText(lastTranscription)
}
} catch (e: Exception) {
- Log.w(TAG, "Chunk transcription error: ${e.message}")
+ Timber.w("Chunk transcription error: ${e.message}")
}
}
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
- Log.d(TAG, "Live recording cancelled (expected when stopping)")
+ Timber.d("Live recording cancelled (expected when stopping)")
} catch (e: Exception) {
- Log.e(TAG, "Error during live recording: ${e.message}", e)
+ Timber.e(e, "Error during live recording: ${e.message}")
_uiState.update {
it.copy(
errorMessage = "Live transcription error: ${e.message}",
@@ -511,7 +510,7 @@ class SpeechToTextViewModel : ViewModel() {
),
)
}
- Log.d(TAG, "Stream transcription: $text")
+ Timber.d("Stream transcription: $text")
}
}
@@ -520,7 +519,7 @@ class SpeechToTextViewModel : ViewModel() {
* iOS Reference: stopRecording() in STTViewModel.swift
*/
private suspend fun stopRecording() {
- Log.i(TAG, "Stopping recording in ${_uiState.value.mode} mode")
+ Timber.i("Stopping recording in ${_uiState.value.mode} mode")
// Stop audio capture
audioCaptureService?.stopCapture()
@@ -569,7 +568,7 @@ class SpeechToTextViewModel : ViewModel() {
return
}
- Log.i(TAG, "Starting batch transcription of ${audioBytes.size} bytes")
+ Timber.i("Starting batch transcription of ${audioBytes.size} bytes")
try {
withContext(Dispatchers.IO) {
@@ -602,10 +601,10 @@ class SpeechToTextViewModel : ViewModel() {
}
}
- Log.i(TAG, "✅ Batch transcription complete: $result (${inferenceTimeMs}ms, $wordCount words)")
+ Timber.i("✅ Batch transcription complete: $result (${inferenceTimeMs}ms, $wordCount words)")
}
} catch (e: Exception) {
- Log.e(TAG, "Batch transcription failed: ${e.message}", e)
+ Timber.e(e, "Batch transcription failed: ${e.message}")
_uiState.update {
it.copy(
errorMessage = "Transcription failed: ${e.message}",
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
index 1420e9a27..15fc021a8 100644
--- 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
@@ -14,6 +14,8 @@ 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.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.automirrored.outlined.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
@@ -36,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.runanywhere.runanywhereai.presentation.chat.components.ModelLoadedToast
import com.runanywhere.runanywhereai.presentation.chat.components.ModelRequiredOverlay
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.util.getModelLogoResIdForName
@@ -53,151 +56,140 @@ import kotlinx.coroutines.launch
* - Audio info display
* - Model status banner
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun TextToSpeechScreen(viewModel: TextToSpeechViewModel = viewModel()) {
+fun TextToSpeechScreen(
+ onBack: () -> Unit = {},
+ 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 = {
+ ConfigureTopBar(
+ title = "Text to Speech",
+ showBack = true,
+ onBack = onBack,
+ actions = {
if (uiState.isModelLoaded) {
- TopAppBar(
- title = {
- Text(
- text = "Text to Speech",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- )
- },
- actions = {
- Surface(
- onClick = { showModelPicker = true },
- shape = RoundedCornerShape(50),
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- ) {
- TTSModelChip(
- modelName = uiState.selectedModelName,
- modifier = Modifier.padding(
- start = 6.dp,
- end = 12.dp,
- top = 6.dp,
- bottom = 6.dp,
- ),
- )
- }
- },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.surface,
- ),
- )
+ Surface(
+ onClick = { showModelPicker = true },
+ shape = RoundedCornerShape(50),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ TTSModelChip(
+ modelName = uiState.selectedModelName,
+ modifier = Modifier.padding(
+ start = 6.dp,
+ end = 12.dp,
+ top = 6.dp,
+ bottom = 6.dp,
+ ),
+ )
+ }
}
},
- ) { 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,
- )
- }
- }
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .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() },
+ )
- 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() },
+ 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,
+ )
+ }
}
- }
- if (!uiState.isModelLoaded && !uiState.isGenerating) {
- ModelRequiredOverlay(
- modality = ModelSelectionContext.TTS,
- onSelectModel = { showModelPicker = true },
- modifier = Modifier.matchParentSize(),
+ 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() },
)
}
+ }
- // Model loaded toast overlay
- ModelLoadedToast(
- modelName = loadedModelToastName,
- isVisible = showModelLoadedToast,
- onDismiss = { showModelLoadedToast = false },
- modifier = Modifier.align(Alignment.TopCenter),
+ if (!uiState.isModelLoaded && !uiState.isGenerating) {
+ ModelRequiredOverlay(
+ modality = ModelSelectionContext.TTS,
+ 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
- }
- },
- )
- }
+ ModelSelectionBottomSheet(
+ context = ModelSelectionContext.TTS,
+ onDismiss = { showModelPicker = false },
+ onModelSelected = { model ->
+ scope.launch {
+ // 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
+ }
+ },
+ )
+ }
}
/**
@@ -313,7 +305,7 @@ private fun ModelStatusBannerTTS(
)
} else if (framework != null && modelName != null) {
Icon(
- imageVector = Icons.Filled.VolumeUp,
+ imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = null,
tint = AppColors.primaryAccent,
modifier = Modifier.size(18.dp),
@@ -572,7 +564,7 @@ private fun AudioInfoSection(
sampleRate?.let {
AudioInfoRow(
- icon = Icons.Outlined.VolumeUp,
+ icon = Icons.AutoMirrored.Outlined.VolumeUp,
label = "Sample Rate",
value = "$it Hz",
)
@@ -713,7 +705,7 @@ private fun ControlsSection(
if (isSystemTTS && isSpeaking) {
Icons.Filled.Stop
} else if (isSystemTTS) {
- Icons.Filled.VolumeUp
+ Icons.AutoMirrored.Filled.VolumeUp
} else {
Icons.Filled.GraphicEq
},
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
index fdb9947fb..3d91ea60a 100644
--- 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
@@ -6,7 +6,7 @@ import android.media.AudioFormat
import android.media.AudioTrack
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.runanywhere.sdk.core.types.InferenceFramework
@@ -36,7 +36,6 @@ 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"
/**
@@ -155,7 +154,7 @@ class TextToSpeechViewModel(
private var systemTtsInit: CompletableDeferred? = null
init {
- Log.i(TAG, "Initializing TTS ViewModel...")
+ Timber.i("Initializing TTS ViewModel...")
// Subscribe to SDK events for TTS model state
viewModelScope.launch {
@@ -182,13 +181,13 @@ class TextToSpeechViewModel(
private fun handleTTSEvent(event: TTSEvent) {
when (event.eventType) {
TTSEvent.TTSEventType.SYNTHESIS_STARTED -> {
- Log.d(TAG, "Synthesis started")
+ Timber.d("Synthesis started")
}
TTSEvent.TTSEventType.SYNTHESIS_COMPLETED -> {
- Log.i(TAG, "Synthesis completed: ${event.durationMs}ms")
+ Timber.i("Synthesis completed: ${event.durationMs}ms")
}
TTSEvent.TTSEventType.SYNTHESIS_FAILED -> {
- Log.e(TAG, "Synthesis failed: ${event.error}")
+ Timber.e("Synthesis failed: ${event.error}")
_uiState.update {
it.copy(
isGenerating = false,
@@ -197,10 +196,10 @@ class TextToSpeechViewModel(
}
}
TTSEvent.TTSEventType.PLAYBACK_STARTED -> {
- Log.d(TAG, "Playback started")
+ Timber.d("Playback started")
}
TTSEvent.TTSEventType.PLAYBACK_COMPLETED -> {
- Log.d(TAG, "Playback completed")
+ Timber.d("Playback completed")
}
}
}
@@ -211,7 +210,7 @@ class TextToSpeechViewModel(
private fun handleModelEvent(event: ModelEvent) {
when (event.eventType) {
ModelEvent.ModelEventType.LOADED -> {
- Log.i(TAG, "✅ TTS model loaded: ${event.modelId}")
+ Timber.i("✅ TTS model loaded: ${event.modelId}")
_uiState.update {
it.copy(
isModelLoaded = true,
@@ -223,7 +222,7 @@ class TextToSpeechViewModel(
shuffleSampleText()
}
ModelEvent.ModelEventType.UNLOADED -> {
- Log.d(TAG, "TTS model unloaded: ${event.modelId}")
+ Timber.d("TTS model unloaded: ${event.modelId}")
_uiState.update {
it.copy(
isModelLoaded = false,
@@ -233,13 +232,13 @@ class TextToSpeechViewModel(
}
}
ModelEvent.ModelEventType.DOWNLOAD_STARTED -> {
- Log.d(TAG, "TTS model download started: ${event.modelId}")
+ Timber.d("TTS model download started: ${event.modelId}")
}
ModelEvent.ModelEventType.DOWNLOAD_COMPLETED -> {
- Log.d(TAG, "TTS model download completed: ${event.modelId}")
+ Timber.d("TTS model download completed: ${event.modelId}")
}
ModelEvent.ModelEventType.DOWNLOAD_FAILED -> {
- Log.e(TAG, "TTS model download failed: ${event.modelId} - ${event.error}")
+ Timber.e("TTS model download failed: ${event.modelId} - ${event.error}")
_uiState.update {
it.copy(
errorMessage = "Download failed: ${event.error}",
@@ -273,11 +272,11 @@ class TextToSpeechViewModel(
fun loadVoice(voiceId: String) {
viewModelScope.launch {
try {
- Log.i(TAG, "Loading TTS voice: $voiceId")
+ Timber.i("Loading TTS voice: $voiceId")
RunAnywhere.loadTTSVoice(voiceId)
updateTTSState()
} catch (e: Exception) {
- Log.e(TAG, "Failed to load TTS voice: ${e.message}", e)
+ Timber.e(e, "Failed to load TTS voice: ${e.message}")
_uiState.update {
it.copy(errorMessage = "Failed to load voice: ${e.message}")
}
@@ -294,7 +293,7 @@ class TextToSpeechViewModel(
modelId: String,
framework: InferenceFramework?,
) {
- Log.i(TAG, "Model loaded notification: $modelName (id: $modelId, framework: ${framework?.displayName})")
+ Timber.i("Model loaded notification: $modelName (id: $modelId, framework: ${framework?.displayName})")
val isSystem = modelId == SYSTEM_TTS_MODEL_ID || framework == InferenceFramework.SYSTEM_TTS
@@ -318,7 +317,7 @@ class TextToSpeechViewModel(
* iOS Reference: initialize() in TTSViewModel
*/
fun initialize() {
- Log.i(TAG, "Initializing TTS ViewModel...")
+ Timber.i("Initializing TTS ViewModel...")
updateTTSState()
}
@@ -393,7 +392,7 @@ class TextToSpeechViewModel(
}
try {
- Log.i(TAG, "Generating speech for text: ${text.take(50)}...")
+ Timber.i("Generating speech for text: ${text.take(50)}...")
val startTime = System.currentTimeMillis()
@@ -430,7 +429,7 @@ class TextToSpeechViewModel(
val processingTime = System.currentTimeMillis() - startTime
if (result.audioData.isEmpty()) {
- Log.i(TAG, "TTS synthesis returned empty audio")
+ Timber.i("TTS synthesis returned empty audio")
_uiState.update {
it.copy(
isGenerating = false,
@@ -443,7 +442,7 @@ class TextToSpeechViewModel(
}
} else {
// ONNX/Piper TTS returns audio data for playback
- Log.i(TAG, "✅ Speech generation complete: ${result.audioData.size} bytes, duration: ${result.duration}s")
+ Timber.i("✅ Speech generation complete: ${result.audioData.size} bytes, duration: ${result.duration}s")
generatedAudioData = result.audioData
@@ -461,7 +460,7 @@ class TextToSpeechViewModel(
}
}
} catch (e: Exception) {
- Log.e(TAG, "Speech generation failed: ${e.message}", e)
+ Timber.e(e, "Speech generation failed: ${e.message}")
_uiState.update {
it.copy(
isGenerating = false,
@@ -492,11 +491,11 @@ class TextToSpeechViewModel(
private fun startPlayback() {
val audioData = generatedAudioData
if (audioData == null || audioData.isEmpty()) {
- Log.w(TAG, "No audio data to play")
+ Timber.w("No audio data to play")
return
}
- Log.i(TAG, "Starting playback of ${audioData.size} bytes")
+ Timber.i("Starting playback of ${audioData.size} bytes")
_uiState.update { it.copy(isPlaying = true) }
playbackJob =
@@ -574,7 +573,7 @@ class TextToSpeechViewModel(
stopPlayback()
}
} catch (e: Exception) {
- Log.e(TAG, "Playback error: ${e.message}", e)
+ Timber.e(e, "Playback error: ${e.message}")
withContext(Dispatchers.Main) {
_uiState.update {
it.copy(
@@ -610,7 +609,7 @@ class TextToSpeechViewModel(
audioTrack?.release()
audioTrack = null
- Log.d(TAG, "Playback stopped")
+ Timber.d("Playback stopped")
}
/**
@@ -626,7 +625,7 @@ class TextToSpeechViewModel(
override fun onCleared() {
super.onCleared()
- Log.i(TAG, "ViewModel cleared, cleaning up resources")
+ Timber.i("ViewModel cleared, cleaning up resources")
stopPlayback()
generatedAudioData = null
systemTts?.shutdown()
@@ -662,7 +661,7 @@ class TextToSpeechViewModel(
tts.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
- Log.d(TAG, "System TTS started")
+ Timber.d("System TTS started")
}
override fun onDone(utteranceId: String?) {
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMScreen.kt
index 81a6b912c..807400e5f 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMScreen.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMScreen.kt
@@ -35,10 +35,10 @@ import androidx.compose.material.icons.outlined.ViewInAr
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -51,10 +51,10 @@ 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.platform.LocalClipboardManager
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -62,6 +62,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.runanywhere.runanywhereai.presentation.components.ConfigureCustomTopBar
import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet
import com.runanywhere.sdk.public.RunAnywhere
import com.runanywhere.sdk.public.extensions.Models.ModelSelectionContext
@@ -88,6 +89,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VLMScreen(
+ onBack: () -> Unit = {},
viewModel: VLMViewModel = viewModel(
factory = androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.getInstance(
LocalContext.current.applicationContext as android.app.Application,
@@ -96,7 +98,7 @@ fun VLMScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
- val clipboardManager = LocalClipboardManager.current
+ val clipboard = LocalClipboard.current
val context = LocalContext.current
// Photo picker launcher
@@ -124,34 +126,40 @@ fun VLMScreen(
}
}
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Vision AI") },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = Color.Black,
- titleContentColor = Color.White,
- ),
- actions = {
- // Show loaded model name (mirrors iOS toolbar trailing item)
- uiState.loadedModelName?.let { name ->
- Text(
- text = name,
- color = Color.Gray,
- fontSize = 12.sp,
- modifier = Modifier.padding(end = 8.dp),
- )
- }
- },
- )
- },
- containerColor = Color.Black,
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues),
- ) {
+ ConfigureCustomTopBar {
+ TopAppBar(
+ title = { Text("Vision AI") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ tint = Color.White,
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Black,
+ titleContentColor = Color.White,
+ ),
+ actions = {
+ uiState.loadedModelName?.let { name ->
+ Text(
+ text = name,
+ color = Color.Gray,
+ fontSize = 12.sp,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ }
+ },
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ ) {
if (!uiState.isModelLoaded) {
ModelRequiredContent(
onSelectModel = { viewModel.setShowModelSelection(true) },
@@ -176,7 +184,16 @@ fun VLMScreen(
isAutoStreaming = uiState.isAutoStreamingEnabled,
onCopy = {
if (uiState.currentDescription.isNotEmpty()) {
- clipboardManager.setText(AnnotatedString(uiState.currentDescription))
+ scope.launch {
+ clipboard.setClipEntry(
+ ClipEntry(
+ android.content.ClipData.newPlainText(
+ "description",
+ uiState.currentDescription,
+ ),
+ ),
+ )
+ }
}
},
modifier = Modifier
@@ -198,29 +215,26 @@ fun VLMScreen(
.weight(0.1f),
)
}
- }
+ }
- // Model selection bottom sheet
- if (uiState.showModelSelection) {
- ModelSelectionBottomSheet(
- context = ModelSelectionContext.VLM,
- onDismiss = { viewModel.setShowModelSelection(false) },
- onModelSelected = { model ->
- scope.launch {
- viewModel.checkModelStatus()
- if (RunAnywhere.isVLMModelLoaded) {
- viewModel.onModelLoaded(modelName = model.name)
- }
+ // Model selection bottom sheet
+ if (uiState.showModelSelection) {
+ ModelSelectionBottomSheet(
+ context = ModelSelectionContext.VLM,
+ onDismiss = { viewModel.setShowModelSelection(false) },
+ onModelSelected = { model ->
+ scope.launch {
+ viewModel.checkModelStatus()
+ if (RunAnywhere.isVLMModelLoaded) {
+ viewModel.onModelLoaded(modelName = model.name)
}
- },
- )
- }
+ }
+ },
+ )
}
}
-// ==========================================================================
// Camera Preview Section — mirrors iOS cameraPreview
-// ==========================================================================
@Composable
private fun CameraPreviewSection(
@@ -288,9 +302,7 @@ private fun CameraPreviewSection(
}
}
-// ==========================================================================
// Camera Permission View — mirrors iOS cameraPermissionView
-// ==========================================================================
@Composable
private fun CameraPermissionView(onRequestPermission: () -> Unit) {
@@ -334,9 +346,7 @@ private fun CameraPermissionView(onRequestPermission: () -> Unit) {
}
}
-// ==========================================================================
// Description Panel — mirrors iOS descriptionPanel exactly
-// ==========================================================================
@Composable
private fun DescriptionPanel(
@@ -433,9 +443,7 @@ private fun DescriptionPanel(
}
}
-// ==========================================================================
// Control Bar (4 buttons) — mirrors iOS controlBar exactly
-// ==========================================================================
@Composable
private fun ControlBar(
@@ -559,9 +567,7 @@ private fun ControlBar(
}
}
-// ==========================================================================
// Model Required Content — mirrors iOS modelRequiredContent
-// ==========================================================================
@Composable
private fun ModelRequiredContent(onSelectModel: () -> Unit) {
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMViewModel.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMViewModel.kt
index 28ae5f07a..6983f7bb9 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMViewModel.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VLMViewModel.kt
@@ -4,7 +4,7 @@ import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri
-import android.util.Log
+import timber.log.Timber
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
@@ -66,7 +66,6 @@ data class VLMUiState(
*/
class VLMViewModel(application: Application) : AndroidViewModel(application) {
companion object {
- private const val TAG = "VLMViewModel"
private const val AUTO_STREAM_INTERVAL_MS = 2500L
}
@@ -93,17 +92,15 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
}
- // ========================================================================
// MODEL
- // ========================================================================
fun checkModelStatus() {
try {
val isLoaded = RunAnywhere.isVLMModelLoaded
_uiState.update { it.copy(isModelLoaded = isLoaded) }
- Log.d(TAG, "VLM model loaded: $isLoaded")
+ Timber.d("VLM model loaded: $isLoaded")
} catch (e: Exception) {
- Log.e(TAG, "Failed to check VLM model status: ${e.message}", e)
+ Timber.e(e, "Failed to check VLM model status: ${e.message}")
_uiState.update { it.copy(isModelLoaded = false) }
}
}
@@ -122,10 +119,8 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(showModelSelection = show) }
}
- // ========================================================================
// CAMERA - Mirrors iOS setupCamera / startCamera / stopCamera
// Uses LifecycleCameraController (CameraX recommended API)
- // ========================================================================
fun checkCameraPermission() {
val context = getApplication()
@@ -207,15 +202,13 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
currentFrameHeight = height
}
} catch (e: Exception) {
- Log.e(TAG, "Frame capture failed: ${e.message}")
+ Timber.e("Frame capture failed: ${e.message}")
} finally {
imageProxy.close()
}
}
- // ========================================================================
// DESCRIBE - Mirrors iOS describeCurrentFrame / describeImage
- // ========================================================================
/**
* Describe the current camera frame. Mirrors iOS describeCurrentFrame().
@@ -251,7 +244,7 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
val image = VLMImage.fromRGBPixels(frameData, w, h)
val options = VLMGenerationOptions(maxTokens = 200, temperature = 0.7f)
- Log.i(TAG, "Describing current camera frame (${w}x${h})")
+ Timber.i("Describing current camera frame (${w}x${h})")
RunAnywhere.processImageStream(
image,
@@ -262,9 +255,9 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
_uiState.update { it.copy(currentDescription = it.currentDescription.trim()) }
- Log.i(TAG, "Frame description completed")
+ Timber.i("Frame description completed")
} catch (e: Exception) {
- Log.e(TAG, "Frame description failed: ${e.message}", e)
+ Timber.e(e, "Frame description failed: ${e.message}")
_uiState.update { it.copy(error = "Processing failed: ${e.message}") }
} finally {
_uiState.update { it.copy(isProcessing = false) }
@@ -297,7 +290,7 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
val image = VLMImage.fromFilePath(tempFile.absolutePath)
val options = VLMGenerationOptions(maxTokens = 300, temperature = 0.7f)
- Log.i(TAG, "Starting VLM streaming for image: ${tempFile.name}")
+ Timber.i("Starting VLM streaming for image: ${tempFile.name}")
RunAnywhere.processImageStream(image, prompt, options)
.collect { token ->
@@ -305,9 +298,9 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
_uiState.update { it.copy(currentDescription = it.currentDescription.trim()) }
- Log.i(TAG, "VLM streaming completed")
+ Timber.i("VLM streaming completed")
} catch (e: Exception) {
- Log.e(TAG, "VLM processing failed: ${e.message}", e)
+ Timber.e(e, "VLM processing failed: ${e.message}")
_uiState.update { it.copy(error = "Processing failed: ${e.message}") }
} finally {
tempFile?.delete()
@@ -316,9 +309,7 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
}
- // ========================================================================
// AUTO-STREAMING - Mirrors iOS toggleAutoStreaming / startAutoStreaming
- // ========================================================================
fun toggleAutoStreaming() {
if (_uiState.value.isAutoStreamingEnabled) {
@@ -387,30 +378,26 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
_uiState.update { it.copy(currentDescription = newDescription.trim()) }
} catch (e: Exception) {
- Log.e(TAG, "Auto-stream VLM error: ${e.message}")
+ Timber.e("Auto-stream VLM error: ${e.message}")
} finally {
_uiState.update { it.copy(isProcessing = false) }
}
}
- // ========================================================================
// CANCEL
- // ========================================================================
fun cancelGeneration() {
try {
RunAnywhere.cancelVLMGeneration()
generationJob?.cancel()
_uiState.update { it.copy(isProcessing = false) }
- Log.d(TAG, "VLM generation cancelled")
+ Timber.d("VLM generation cancelled")
} catch (e: Exception) {
- Log.e(TAG, "Failed to cancel VLM generation: ${e.message}", e)
+ Timber.e(e, "Failed to cancel VLM generation: ${e.message}")
}
}
- // ========================================================================
// IMAGE SELECTION
- // ========================================================================
fun setSelectedImage(uri: Uri?) {
_uiState.update {
@@ -418,9 +405,7 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
}
- // ========================================================================
// HELPERS
- // ========================================================================
private suspend fun copyUriToTempFile(uri: Uri): File? = withContext(Dispatchers.IO) {
try {
@@ -434,7 +419,7 @@ class VLMViewModel(application: Application) : AndroidViewModel(application) {
}
tempFile
} catch (e: Exception) {
- Log.e(TAG, "Failed to copy URI to temp file: ${e.message}", e)
+ Timber.e(e, "Failed to copy URI to temp file: ${e.message}")
null
}
}
diff --git a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VisionHubScreen.kt b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VisionHubScreen.kt
index 549c66761..be1dcae6a 100644
--- a/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VisionHubScreen.kt
+++ b/examples/android/RunAnywhereAI/app/src/main/java/com/runanywhere/runanywhereai/presentation/vision/VisionHubScreen.kt
@@ -17,20 +17,18 @@ import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import com.runanywhere.runanywhereai.presentation.components.ConfigureTopBar
+import com.runanywhere.runanywhereai.ui.theme.AppColors
+import com.runanywhere.runanywhereai.ui.theme.Dimensions
/**
* Vision Hub Screen — Matches iOS VisionHubView exactly.
@@ -41,63 +39,55 @@ import androidx.compose.ui.unit.sp
*
* iOS Reference: examples/ios/RunAnywhereAI/.../App/ContentView.swift — VisionHubView
*/
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VisionHubScreen(
onNavigateToVLM: () -> Unit,
onNavigateToImageGeneration: () -> Unit = {},
) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Vision") },
- )
- },
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp, vertical = 8.dp),
- ) {
+ ConfigureTopBar(title = "Vision")
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = Dimensions.large, vertical = Dimensions.smallMedium),
+ ) {
// Section header
Text(
"Vision AI",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp, bottom = 8.dp),
+ modifier = Modifier.padding(start = Dimensions.xSmall, bottom = Dimensions.smallMedium),
)
// Vision Chat (VLM)
FeatureCard(
icon = Icons.Filled.CameraAlt,
- iconColor = Color(0xFF9C27B0), // Purple
+ iconColor = AppColors.featureCamera,
title = "Vision Chat",
subtitle = "Chat with images using your camera or photos",
onClick = onNavigateToVLM,
)
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(Dimensions.smallMedium))
// Image Generation (placeholder for future diffusion model support)
FeatureCard(
icon = Icons.Filled.PhotoLibrary,
- iconColor = Color(0xFFE91E63), // Pink
+ iconColor = AppColors.featurePink,
title = "Image Generation",
subtitle = "Create images from text prompts",
onClick = onNavigateToImageGeneration,
)
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(Dimensions.large))
// Footer
Text(
"Understand and create visual content with AI",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 4.dp),
+ modifier = Modifier.padding(start = Dimensions.xSmall),
)
- }
}
}
@@ -116,7 +106,7 @@ private fun FeatureCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
- shape = RoundedCornerShape(12.dp),
+ shape = RoundedCornerShape(Dimensions.cornerRadiusXLarge),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
@@ -124,7 +114,7 @@ private fun FeatureCard(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp),
+ .padding(Dimensions.large),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -133,16 +123,15 @@ private fun FeatureCard(
tint = iconColor,
modifier = Modifier.size(32.dp),
)
- Spacer(modifier = Modifier.width(16.dp))
+ Spacer(modifier = Modifier.width(Dimensions.large))
Column(modifier = Modifier.weight(1f)) {
Text(
title,
- fontWeight = FontWeight.Medium,
- fontSize = 16.sp,
+ style = MaterialTheme.typography.bodyLarge,
)
Text(
subtitle,
- fontSize = 13.sp,
+ style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
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
index c8ef3500f..4e7dbbc64 100644
--- 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
@@ -13,23 +13,21 @@ import kotlinx.coroutines.delay
import kotlin.math.*
import kotlin.random.Random
-// ============================================================
-// VoiceAssistantParticleView.kt
+// VoiceAssistantParticleView
//
// Optimized particle animation for voice assistant.
// Performance-focused for smooth 60fps on low-memory devices.
//
// Features:
// - ~900 particles on a Fibonacci sphere
-// - Sphere ↔ Ring morph transition on listening state
+// - Sphere / Ring morph transition on listening state
// - Amplitude-driven ring expansion
// - Localized touch/drag scatter
// - Batched point drawing (single draw call for most particles)
// - Pre-computed per-frame values to avoid redundant trig
// - Cheap noise replacement using pre-baked sin LUT
-// ============================================================
-// ---------- Data ----------
+// Data
/**
* Immutable particle data generated once. All fields are primitives
@@ -53,7 +51,7 @@ private class ParticleData(
val personalMorphBias: Float,
)
-// ---------- Constants ----------
+// Constants
private const val PARTICLE_COUNT = 900
private const val TWO_PI = (PI * 2.0).toFloat()
@@ -61,7 +59,7 @@ private const val TWO_PI = (PI * 2.0).toFloat()
// Sin look-up table for cheap noise (256 entries)
private val SIN_LUT = FloatArray(256) { sin(it.toFloat() / 256f * TWO_PI).toFloat() }
-// ---------- Particle Generation (Fibonacci Sphere) ----------
+// Particle Generation (Fibonacci Sphere)
private fun generateParticles(count: Int): Array {
val goldenRatio = (1.0 + sqrt(5.0)) / 2.0
@@ -92,7 +90,7 @@ private fun generateParticles(count: Int): Array {
}
}
-// ---------- Cheap Noise (LUT-based, no sin() per call) ----------
+// Cheap Noise (LUT-based, no sin() per call)
/** Fast hash via LUT – returns 0..1. */
@Suppress("NOTHING_TO_INLINE")
@@ -112,7 +110,7 @@ private inline fun cheapNoise(phase: Float, time: Float): Float {
return (a + b) * 0.5f // 0..1
}
-// ---------- Utility ----------
+// Utility
@Suppress("NOTHING_TO_INLINE")
private inline fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
@@ -123,7 +121,7 @@ private inline fun smoothstep(edge0: Float, edge1: Float, x: Float): Float {
return t * t * (3f - 2f * t)
}
-// ---------- Per-Frame Shared State (computed once, used by all particles) ----------
+// Per-Frame Shared State (computed once, used by all particles)
private class FrameState(
val time: Float,
@@ -212,7 +210,7 @@ private fun buildFrameState(
)
}
-// ---------- Particle Canvas ----------
+// Particle Canvas
@Composable
fun VoiceAssistantParticleCanvas(
@@ -257,7 +255,7 @@ fun VoiceAssistantParticleCanvas(
}
}
-// ---------- Batched Drawing ----------
+// Batched Drawing
/**
* Draws all particles with minimal allocations.
@@ -276,7 +274,7 @@ private fun DrawScope.drawParticlesBatched(
val invViewScale400 = f.viewScale / 400f
for (p in particles) {
- // --- Sphere rotation ---
+ // Sphere rotation
var rsx = p.sx * f.cosA - p.sz * f.sinA
val rsy = p.sy
var rsz = p.sx * f.sinA + p.sz * f.cosA
@@ -284,19 +282,19 @@ private fun DrawScope.drawParticlesBatched(
val rsyB = rsy * f.sphereBreath
rsz *= f.sphereBreath
- // --- Ring position ---
+ // Ring position
val ringAngle = p.index * TWO_PI + f.ringTimeOffset
val ringRadius = f.baseRingWithPulse + f.ringTimeSin + p.radiusOffset * 0.18f
val ringX = cos(ringAngle) * ringRadius
val ringY = sin(ringAngle) * ringRadius
- // --- Morph ---
+ // Morph
val personalMorph = (f.morphProgress * p.personalSpeedFactor + p.personalMorphBias)
.coerceIn(0f, 1f)
var sm = personalMorph * personalMorph * (3f - 2f * personalMorph)
sm = sm * sm * (3f - 2f * sm)
- // --- Wander + spiral (only during transition) ---
+ // Wander + spiral (only during transition)
var wx = 0f; var wy = 0f; var wz = 0f
var spiralX = 0f; var spiralY = 0f
if (f.wanderPhase > 0.01f) {
@@ -309,17 +307,17 @@ private fun DrawScope.drawParticlesBatched(
spiralY = sin(sa) * sr
}
- // --- Interpolate sphere → ring ---
+ // Interpolate sphere to ring
var finalX = lerp(rsx, ringX, sm) + wx + spiralX
var finalY = lerp(rsyB, ringY, sm) + wy + spiralY
val finalZ = lerp(rsz, 0f, sm) + wz
- // --- Perspective ---
+ // Perspective
val zDepth = finalZ + 2.5f
var screenX = (finalX / zDepth) * projScale
var screenY = (finalY / zDepth) * projScale
- // --- Touch scatter (skip entirely when not active) ---
+ // Touch scatter (skip entirely when not active)
var touchInfluence = 0f
if (f.hasScatter) {
val dx = screenX - f.touchPoint.x
@@ -341,29 +339,29 @@ private fun DrawScope.drawParticlesBatched(
}
}
- // --- Screen position ---
+ // Screen position
val projX = f.centerX + screenX * f.viewScale
val projY = f.centerY - screenY * f.viewScale * f.aspectRatio
- // --- Skip off-screen particles ---
+ // Skip off-screen particles
if (projX < -20f || projX > size.width + 20f ||
projY < -20f || projY > size.height + 20f) continue
- // --- Size ---
+ // Size
val transGlow = 1f + f.wanderPhase * 0.25f
var pointSize = 6f * (2.8f / zDepth) * transGlow
pointSize *= (1f + touchInfluence * 0.2f)
pointSize = pointSize.coerceIn(2f, 8f)
val radius = pointSize * invViewScale400
- // --- Color ---
+ // Color
val energy = sm * (0.5f + f.amplitude * 0.5f)
val bright = f.brightBase + energy * f.brightEnergyScale + touchInfluence * 0.15f
val r = (lerp(f.baseR, f.activeR, energy) * bright).coerceIn(0f, 1f)
val g = (lerp(f.baseG, f.activeG, energy) * bright).coerceIn(0f, 1f)
val b = (lerp(f.baseB, f.activeB, energy) * bright).coerceIn(0f, 1f)
- // --- Alpha ---
+ // Alpha
val depthShade = 0.5f + 0.5f * (1f - (zDepth - 1.8f) * 0.5f)
val alpha = lerp(depthShade * 0.6f, 0.85f, sm).coerceIn(0.1f, 0.85f)
@@ -390,7 +388,7 @@ private fun DrawScope.drawParticlesBatched(
}
}
- // --- Batch draw all small particles in one call ---
+ // Batch draw all small particles in one call
if (batchPoints.isNotEmpty()) {
// drawPoints with StrokeCap.Round renders small circles efficiently
drawPoints(
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
index 706f02ef9..4e7bd8885 100644
--- 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
@@ -14,6 +14,7 @@ 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.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
@@ -42,6 +43,7 @@ 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.components.ConfigureTopBar
import com.runanywhere.runanywhereai.presentation.models.ModelSelectionBottomSheet
import com.runanywhere.runanywhereai.ui.theme.AppColors
import com.runanywhere.runanywhereai.ui.theme.AppTypography
@@ -58,7 +60,7 @@ import kotlin.math.min
*
* Complete voice pipeline UI with VAD, STT, LLM, and TTS
*/
-@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceAssistantScreen(viewModel: VoiceAssistantViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
@@ -93,73 +95,65 @@ fun VoiceAssistantScreen(viewModel: VoiceAssistantViewModel = viewModel()) {
}
}
- // When !allModelsLoaded show VoicePipelineSetupView as main content; when loaded show mainVoiceUI with Scaffold
- Scaffold(
- topBar = {
+ ConfigureTopBar(
+ title = "Voice",
+ actions = {
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),
- )
+ 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,
+ )
+ }
}
},
- ) { 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() },
- )
- }
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .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() },
+ )
}
}
@@ -179,7 +173,7 @@ fun VoiceAssistantScreen(viewModel: VoiceAssistantViewModel = viewModel()) {
)
}
}
-
+
// Model selection bottom sheets - uses real SDK models
// ModelSelectionSheet(context: .stt/.llm/.tts)
if (showSTTModelSelection) {
@@ -193,7 +187,7 @@ fun VoiceAssistantScreen(viewModel: VoiceAssistantViewModel = viewModel()) {
},
)
}
-
+
if (showLLMModelSelection) {
ModelSelectionBottomSheet(
context = ModelSelectionContext.LLM,
@@ -205,7 +199,7 @@ fun VoiceAssistantScreen(viewModel: VoiceAssistantViewModel = viewModel()) {
},
)
}
-
+
if (showTTSModelSelection) {
ModelSelectionBottomSheet(
context = ModelSelectionContext.TTS,
@@ -307,7 +301,7 @@ private fun VoicePipelineSetupView(
step = 3,
title = "Text to Speech",
subtitle = "Converts responses to audio",
- icon = Icons.Default.VolumeUp,
+ icon = Icons.AutoMirrored.Filled.VolumeUp,
color = AppColors.primaryPurple,
selectedFramework = ttsModel?.framework,
selectedModel = ttsModel?.name,
@@ -690,7 +684,7 @@ private fun MainVoiceAssistantUI(
color = AppColors.primaryGreen,
)
ModelBadge(
- icon = Icons.Default.VolumeUp,
+ icon = Icons.AutoMirrored.Filled.VolumeUp,
label = "TTS",
value = uiState.ttsModel?.name ?: "Not set",
color = AppColors.primaryPurple,
@@ -1102,7 +1096,7 @@ private fun MicrophoneButton(
when {
!hasPermission -> Icons.Default.MicOff
sessionState == SessionState.LISTENING -> Icons.Default.Mic
- sessionState == SessionState.SPEAKING -> Icons.Default.VolumeUp
+ sessionState == SessionState.SPEAKING -> Icons.AutoMirrored.Filled.VolumeUp
else -> Icons.Default.Mic
},
contentDescription = "Microphone",
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
index e5b50c692..84db1970e 100644
--- 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
@@ -5,7 +5,7 @@ import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
-import android.util.Log
+import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.runanywhere.runanywhereai.domain.models.SessionState
@@ -40,8 +40,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
-private const val TAG = "VoiceAssistantVM"
-
/**
* Model Load State matching iOS ModelLoadState
*/
@@ -206,7 +204,7 @@ class VoiceAssistantViewModel(
fun initialize(context: Context) {
if (audioCaptureService == null) {
audioCaptureService = AudioCaptureService(context)
- Log.i(TAG, "AudioCaptureService initialized")
+ Timber.i("AudioCaptureService initialized")
}
}
@@ -230,7 +228,7 @@ class VoiceAssistantViewModel(
if (level > speechThreshold) {
// Speech detected
if (!isSpeechActive) {
- Log.d(TAG, "🎙️ Speech started (level: $level)")
+ Timber.d("🎙️ Speech started (level: $level)")
isSpeechActive = true
_uiState.update { it.copy(isSpeechDetected = true) }
}
@@ -253,17 +251,17 @@ class VoiceAssistantViewModel(
if (currentLevel <= speechThreshold && lastSpeechTime > 0) {
val silenceTime = System.currentTimeMillis() - lastSpeechTime
if (silenceTime > silenceDurationMs) {
- Log.d(TAG, "🔇 Speech ended after ${silenceTime}ms of silence")
+ Timber.d("🔇 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)")
+ Timber.i("🚀 Auto-triggering voice pipeline (audio: $audioSize bytes)")
processCurrentAudio()
} else {
- Log.d(TAG, "Audio too short to process ($audioSize bytes), resetting buffer")
+ Timber.d("Audio too short to process ($audioSize bytes), resetting buffer")
audioBuffer.reset()
}
}
@@ -279,7 +277,7 @@ class VoiceAssistantViewModel(
*/
private fun processCurrentAudio() {
if (isProcessingTurn) {
- Log.d(TAG, "Already processing a turn, skipping")
+ Timber.d("Already processing a turn, skipping")
return
}
@@ -306,7 +304,7 @@ class VoiceAssistantViewModel(
silenceDetectionJob?.cancel()
audioCaptureService?.stopCapture()
- Log.i(TAG, "🔄 Processing ${audioData.size} bytes through voice pipeline...")
+ Timber.i("🔄 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)
@@ -318,8 +316,7 @@ class VoiceAssistantViewModel(
val transcription = result.transcription
val response = result.response
- Log.i(
- TAG,
+ Timber.i(
"✅ Voice pipeline result - speechDetected: ${result.speechDetected}, " +
"transcription: ${transcription?.take(50)}, " +
"response: ${response?.take(50)}",
@@ -336,15 +333,15 @@ class VoiceAssistantViewModel(
// 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)")
+ Timber.i("🔊 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")
+ Timber.d("No synthesized audio, resuming listening immediately")
resumeListening()
}
} else {
- Log.i(TAG, "No speech detected in audio")
+ Timber.i("No speech detected in audio")
_uiState.update {
it.copy(
errorMessage = if (!result.speechDetected) "No speech detected" else null,
@@ -353,7 +350,7 @@ class VoiceAssistantViewModel(
resumeListening()
}
} catch (e: Exception) {
- Log.e(TAG, "Error processing voice: ${e.message}", e)
+ Timber.e(e, "Error processing voice: ${e.message}")
_uiState.update {
it.copy(
sessionState = SessionState.ERROR,
@@ -388,7 +385,7 @@ class VoiceAssistantViewModel(
)
}
- Log.i(TAG, "🎙️ Resuming listening for next turn...")
+ Timber.i("🎙️ Resuming listening for next turn...")
// Restart audio capture
audioRecordingJob =
@@ -408,9 +405,9 @@ class VoiceAssistantViewModel(
checkSpeechState(normalizedLevel)
}
} catch (e: kotlinx.coroutines.CancellationException) {
- Log.d(TAG, "Audio recording cancelled")
+ Timber.d("Audio recording cancelled")
} catch (e: Exception) {
- Log.e(TAG, "Audio capture error on resume", e)
+ Timber.e(e, "Audio capture error on resume")
}
}
@@ -432,12 +429,12 @@ class VoiceAssistantViewModel(
*/
private fun playAudio(audioData: ByteArray) {
if (audioData.isEmpty()) {
- Log.w(TAG, "No audio data to play")
+ Timber.w("No audio data to play")
resumeListening()
return
}
- Log.i(TAG, "🔊 Starting TTS playback (${audioData.size} bytes)")
+ Timber.i("🔊 Starting TTS playback (${audioData.size} bytes)")
isPlayingAudio = true
_uiState.update {
@@ -464,7 +461,7 @@ class VoiceAssistantViewModel(
}
val pcmData = audioData.copyOfRange(headerSize, audioData.size)
- Log.d(TAG, "PCM data size: ${pcmData.size} bytes (skipped $headerSize byte header)")
+ Timber.d("PCM data size: ${pcmData.size} bytes (skipped $headerSize byte header)")
val bufferSize = AudioTrack.getMinBufferSize(ttsSampleRate, channelConfig, audioFormat)
@@ -490,11 +487,11 @@ class VoiceAssistantViewModel(
audioTrack?.write(pcmData, 0, pcmData.size)
audioTrack?.play()
- Log.i(TAG, "🔊 TTS playback started")
+ Timber.i("🔊 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")
+ Timber.d("Expected playback duration: ${durationMs}ms")
// Wait for playback to complete
var elapsed = 0L
@@ -503,14 +500,14 @@ class VoiceAssistantViewModel(
elapsed += 100
}
- Log.i(TAG, "🔊 TTS playback completed")
+ Timber.i("🔊 TTS playback completed")
withContext(Dispatchers.Main) {
stopAudioPlayback()
resumeListening()
}
} catch (e: Exception) {
- Log.e(TAG, "Audio playback error: ${e.message}", e)
+ Timber.e(e, "Audio playback error: ${e.message}")
withContext(Dispatchers.Main) {
stopAudioPlayback()
resumeListening()
@@ -532,11 +529,11 @@ class VoiceAssistantViewModel(
audioTrack?.stop()
audioTrack?.release()
} catch (e: Exception) {
- Log.w(TAG, "Error stopping AudioTrack: ${e.message}")
+ Timber.w("Error stopping AudioTrack: ${e.message}")
}
audioTrack = null
- Log.d(TAG, "Audio playback stopped")
+ Timber.d("Audio playback stopped")
}
init {
@@ -582,7 +579,7 @@ class VoiceAssistantViewModel(
currentLLMModel = event.modelId,
)
}
- Log.i(TAG, "✅ LLM model loaded: ${event.modelId}")
+ Timber.i("✅ LLM model loaded: ${event.modelId}")
}
EventCategory.STT -> {
_uiState.update {
@@ -592,7 +589,7 @@ class VoiceAssistantViewModel(
whisperModel = event.modelId,
)
}
- Log.i(TAG, "✅ STT model loaded: ${event.modelId}")
+ Timber.i("✅ STT model loaded: ${event.modelId}")
}
EventCategory.TTS -> {
_uiState.update {
@@ -602,7 +599,7 @@ class VoiceAssistantViewModel(
ttsVoice = event.modelId,
)
}
- Log.i(TAG, "✅ TTS model loaded: ${event.modelId}")
+ Timber.i("✅ TTS model loaded: ${event.modelId}")
}
else -> { /* Ignore other categories */ }
}
@@ -696,9 +693,9 @@ class VoiceAssistantViewModel(
)
}
- Log.i(TAG, "📊 Model states synced - STT: ${states.stt.isLoaded}, LLM: ${states.llm.isLoaded}, TTS: ${states.tts.isLoaded}")
+ Timber.i("📊 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}")
+ Timber.w("Could not sync model states: ${e.message}")
}
}
@@ -722,7 +719,7 @@ class VoiceAssistantViewModel(
fun startSession() {
viewModelScope.launch {
try {
- Log.i(TAG, "Starting conversation...")
+ Timber.i("Starting conversation...")
_uiState.update {
it.copy(
@@ -736,7 +733,7 @@ class VoiceAssistantViewModel(
// Check if all models are loaded
val uiStateValue = _uiState.value
if (!uiStateValue.allModelsLoaded) {
- Log.w(TAG, "Cannot start: Not all models loaded")
+ Timber.w("Cannot start: Not all models loaded")
_uiState.update {
it.copy(
sessionState = SessionState.ERROR,
@@ -749,7 +746,7 @@ class VoiceAssistantViewModel(
// Initialize audio capture if not already done
val audioCapture = audioCaptureService
if (audioCapture == null) {
- Log.e(TAG, "AudioCaptureService not initialized")
+ Timber.e("AudioCaptureService not initialized")
_uiState.update {
it.copy(
sessionState = SessionState.ERROR,
@@ -761,7 +758,7 @@ class VoiceAssistantViewModel(
// Check microphone permission
if (!audioCapture.hasRecordPermission()) {
- Log.e(TAG, "No microphone permission")
+ Timber.e("No microphone permission")
_uiState.update {
it.copy(
sessionState = SessionState.ERROR,
@@ -783,7 +780,7 @@ class VoiceAssistantViewModel(
handleVoiceSessionEvent(event)
}
} catch (e: Exception) {
- Log.e(TAG, "Session event error", e)
+ Timber.e(e, "Session event error")
}
}
@@ -799,7 +796,7 @@ class VoiceAssistantViewModel(
)
}
- Log.i(TAG, "Voice session started, starting audio capture...")
+ Timber.i("Voice session started, starting audio capture...")
// Reset speech state tracking
isSpeechActive = false
@@ -828,9 +825,9 @@ class VoiceAssistantViewModel(
checkSpeechState(normalizedLevel)
}
} catch (e: kotlinx.coroutines.CancellationException) {
- Log.d(TAG, "Audio recording cancelled (expected when stopping)")
+ Timber.d("Audio recording cancelled (expected when stopping)")
} catch (e: Exception) {
- Log.e(TAG, "Audio capture error", e)
+ Timber.e(e, "Audio capture error")
_uiState.update {
it.copy(
errorMessage = "Audio capture error: ${e.message}",
@@ -848,7 +845,7 @@ class VoiceAssistantViewModel(
}
}
} catch (e: Exception) {
- Log.e(TAG, "Failed to start session", e)
+ Timber.e(e, "Failed to start session")
_uiState.update {
it.copy(
sessionState = SessionState.ERROR,
@@ -866,7 +863,7 @@ class VoiceAssistantViewModel(
private fun handleVoiceSessionEvent(event: VoiceSessionEvent) {
when (event) {
is VoiceSessionEvent.Started -> {
- Log.i(TAG, "Voice session started")
+ Timber.i("Voice session started")
_uiState.update {
it.copy(
sessionState = SessionState.LISTENING,
@@ -880,12 +877,12 @@ class VoiceAssistantViewModel(
}
is VoiceSessionEvent.SpeechStarted -> {
- Log.d(TAG, "Speech detected")
+ Timber.d("Speech detected")
_uiState.update { it.copy(isSpeechDetected = true) }
}
is VoiceSessionEvent.Processing -> {
- Log.i(TAG, "Processing speech...")
+ Timber.i("Processing speech...")
_uiState.update {
it.copy(
sessionState = SessionState.PROCESSING,
@@ -895,22 +892,22 @@ class VoiceAssistantViewModel(
}
is VoiceSessionEvent.Transcribed -> {
- Log.i(TAG, "Transcription: ${event.text}")
+ Timber.i("Transcription: ${event.text}")
_uiState.update { it.copy(currentTranscript = event.text) }
}
is VoiceSessionEvent.Responded -> {
- Log.i(TAG, "Response: ${event.text.take(50)}...")
+ Timber.i("Response: ${event.text.take(50)}...")
_uiState.update { it.copy(assistantResponse = event.text) }
}
is VoiceSessionEvent.Speaking -> {
- Log.d(TAG, "Playing TTS audio")
+ Timber.d("Playing TTS audio")
_uiState.update { it.copy(sessionState = SessionState.SPEAKING) }
}
is VoiceSessionEvent.TurnCompleted -> {
- Log.i(TAG, "Turn completed")
+ Timber.i("Turn completed")
_uiState.update {
it.copy(
currentTranscript = event.transcript,
@@ -922,7 +919,7 @@ class VoiceAssistantViewModel(
}
is VoiceSessionEvent.Stopped -> {
- Log.i(TAG, "Voice session stopped")
+ Timber.i("Voice session stopped")
_uiState.update {
it.copy(
sessionState = SessionState.DISCONNECTED,
@@ -932,7 +929,7 @@ class VoiceAssistantViewModel(
}
is VoiceSessionEvent.Error -> {
- Log.e(TAG, "Voice session error: ${event.message}")
+ Timber.e("Voice session error: ${event.message}")
_uiState.update {
it.copy(
errorMessage = event.message,
@@ -952,7 +949,7 @@ class VoiceAssistantViewModel(
*/
fun stopSession() {
viewModelScope.launch {
- Log.i(TAG, "Stopping conversation...")
+ Timber.i("Stopping conversation...")
// Reset speech state
isProcessingTurn = false
@@ -978,7 +975,7 @@ class VoiceAssistantViewModel(
val audioSize = audioData.size
audioBuffer.reset()
- Log.i(TAG, "Captured audio: $audioSize bytes")
+ Timber.i("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
@@ -994,7 +991,7 @@ class VoiceAssistantViewModel(
}
try {
- Log.i(TAG, "Processing audio through voice pipeline...")
+ Timber.i("Processing audio through voice pipeline...")
// Process audio through STT → LLM → TTS pipeline
// Run on Default dispatcher to avoid blocking main thread (fixes ANR)
@@ -1006,8 +1003,7 @@ class VoiceAssistantViewModel(
val transcription = result.transcription
val response = result.response
- Log.i(
- TAG,
+ Timber.i(
"Voice pipeline result - speechDetected: ${result.speechDetected}, " +
"transcription: ${transcription?.take(50)}, " +
"response: ${response?.take(50)}",
@@ -1025,11 +1021,11 @@ class VoiceAssistantViewModel(
// 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)")
+ Timber.i("🔊 Playing TTS response (${synthesizedAudio.size} bytes)")
playAudio(synthesizedAudio)
}
} else {
- Log.i(TAG, "No speech detected in audio")
+ Timber.i("No speech detected in audio")
_uiState.update {
it.copy(
sessionState = SessionState.DISCONNECTED,
@@ -1038,7 +1034,7 @@ class VoiceAssistantViewModel(
}
}
} catch (e: Exception) {
- Log.e(TAG, "Error processing voice: ${e.message}", e)
+ Timber.e(e, "Error processing voice: ${e.message}")
_uiState.update {
it.copy(
sessionState = SessionState.ERROR,
@@ -1047,7 +1043,7 @@ class VoiceAssistantViewModel(
}
}
} else {
- Log.i(TAG, "Audio too short to process ($audioSize bytes)")
+ Timber.i("Audio too short to process ($audioSize bytes)")
// Reset UI state without processing
_uiState.update {
it.copy(
@@ -1064,7 +1060,7 @@ class VoiceAssistantViewModel(
RunAnywhere.stopVoiceSession()
voiceSessionFlow = null
- Log.i(TAG, "Conversation stopped")
+ Timber.i("Conversation stopped")
}
}
@@ -1100,7 +1096,7 @@ class VoiceAssistantViewModel(
// Don't reset sttLoadState - model may already be loaded by ModelSelectionBottomSheet
)
}
- Log.i(TAG, "STT model selected: $name ($modelId)")
+ Timber.i("STT model selected: $name ($modelId)")
// Sync with SDK to get actual load state (model may already be loaded)
viewModelScope.launch {
syncModelStates()
@@ -1126,7 +1122,7 @@ class VoiceAssistantViewModel(
// Don't reset llmLoadState - model may already be loaded by ModelSelectionBottomSheet
)
}
- Log.i(TAG, "LLM model selected: $name ($modelId)")
+ Timber.i("LLM model selected: $name ($modelId)")
// Sync with SDK to get actual load state (model may already be loaded)
viewModelScope.launch {
syncModelStates()
@@ -1152,7 +1148,7 @@ class VoiceAssistantViewModel(
// Don't reset ttsLoadState - model may already be loaded by ModelSelectionBottomSheet
)
}
- Log.i(TAG, "TTS model selected: $name ($modelId)")
+ Timber.i("TTS model selected: $name ($modelId)")
// Sync with SDK to get actual load state (model may already be loaded)
viewModelScope.launch {
syncModelStates()
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
index 6073c6d5c..cc83ea6dc 100644
--- 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
@@ -12,9 +12,7 @@ import androidx.compose.ui.graphics.Color
* Dark theme backgrounds: Deep dark blue-gray matching website aesthetic
*/
object AppColors {
- // ====================
- // PRIMARY ACCENT COLORS - RunAnywhere Brand Colors
- // ====================
+ // Primary Accent 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
@@ -24,17 +22,20 @@ object AppColors {
val primaryYellow = Color(0xFFEAB308) // Yellow-500
val primaryPurple = Color(0xFF8B5CF6) // Violet-500 - purple accent
- // ====================
- // TEXT COLORS - RunAnywhere Theme
- // ====================
+ // Feature icon colors (hub screens)
+ val featureBlue = Color(0xFF2196F3)
+ val featureGreen = Color(0xFF4CAF50)
+ val featureDeepPurple = Color(0xFF673AB7)
+ val featurePink = Color(0xFFE91E63)
+ val featureCamera = Color(0xFF9C27B0)
+
+ // Text Colors
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
- // ====================
+ // Background Colors
// Light mode - clean, modern backgrounds
val backgroundPrimary = Color(0xFFFFFFFF) // Pure white
val backgroundSecondary = Color(0xFFF8FAFC) // Slate-50 - very light gray
@@ -51,9 +52,7 @@ object AppColors {
val backgroundGray5Dark = Color(0xFF2A3142) // Medium dark gray
val backgroundGray6Dark = Color(0xFF353B4A) // Lighter dark gray
- // ====================
- // MESSAGE BUBBLE COLORS - RunAnywhere Theme
- // ====================
+ // Message Bubble Colors
// 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
@@ -68,9 +67,22 @@ object AppColors {
val messageBubbleUserDark = Color(0xFFCC4400) // Darker orange-red (80% brightness of primaryAccent)
val messageBubbleAssistantDark = backgroundGray5Dark // Dark gray
- // ====================
- // BADGE/TAG COLORS
- // ====================
+ // User Bubble — ChatGPT-style solid colors
+ val userBubbleSolid = Color(0xFFEFEFEF) // Light mode: light gray
+ val userBubbleSolidDark = Color(0xFF2F3541) // Dark mode: slightly lighter
+
+ /**
+ * Theme-aware solid color for user message bubbles (no gradient).
+ */
+ @Composable
+ fun userBubbleColor(): Color {
+ return if (isSystemInDarkTheme()) userBubbleSolidDark else userBubbleSolid
+ }
+
+ // LoRA Colors
+ val loraBadgeBg = primaryPurple.copy(alpha = 0.10f)
+
+ // 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)
@@ -79,15 +91,11 @@ object AppColors {
val badgeRed = primaryRed.copy(alpha = 0.2f)
val badgeGray = Color.Gray.copy(alpha = 0.2f)
- // ====================
- // MODEL INFO COLORS - RunAnywhere Theme
- // ====================
+ // Model Info Colors
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
- // ====================
+ // Thinking Mode Colors
// 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)
@@ -101,9 +109,7 @@ object AppColors {
val thinkingBackgroundDark = primaryAccent.copy(alpha = 0.15f)
val thinkingContentBackgroundDark = backgroundGray6Dark
- // ====================
- // STATUS COLORS - RunAnywhere Theme
- // ====================
+ // Status Colors
val statusGreen = primaryGreen
val statusOrange = primaryOrange
val statusRed = primaryRed
@@ -113,9 +119,7 @@ object AppColors {
// Warning color - matches iOS orange for error states
val warningOrange = primaryOrange
- // ====================
- // SHADOW COLORS
- // ====================
+ // 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)
@@ -127,43 +131,31 @@ object AppColors {
val shadowModelBadge = primaryAccent.copy(alpha = 0.3f) // Brand primary
val shadowTypingIndicator = shadowLight
- // ====================
- // OVERLAY COLORS
- // ====================
+ // 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
- // ====================
+ // Border Colors
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
- // ====================
+ // Dividers
val divider = Color(0xFFCBD5E1) // Slate-300 - light divider
val dividerDark = Color(0xFF2A3142) // Dark divider matching website
- // ====================
- // CARDS & SURFACES
- // ====================
+ // Cards & Surfaces
val cardBackground = backgroundSecondary
val cardBackgroundDark = backgroundSecondaryDark
- // ====================
- // TYPING INDICATOR - RunAnywhere Theme
- // ====================
+ // Typing Indicator
val typingIndicatorDots = primaryAccent.copy(alpha = 0.7f) // Brand primary
val typingIndicatorBackground = backgroundGray5
val typingIndicatorBorder = borderLight
val typingIndicatorText = textSecondary.copy(alpha = 0.8f)
- // ====================
- // GRADIENT HELPERS
- // ====================
+ // Gradient Helpers
/**
* User message bubble gradient (orange-red brand color)
@@ -235,9 +227,7 @@ object AppColors {
colors = listOf(thinkingProgressBackground, thinkingProgressBackgroundGradientEnd),
)
- // ====================
- // HELPER FUNCTIONS
- // ====================
+ // Helper Functions
/**
* Get framework-specific badge color
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
index 8b17074fa..cd59b1182 100644
--- 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
@@ -8,9 +8,7 @@ import androidx.compose.ui.unit.dp
* All values extracted from iOS implementation for pixel-perfect Android replication
*/
object Dimensions {
- // ====================
- // PADDING VALUES
- // ====================
+ // Padding Values
val xxSmall = 2.dp
val xSmall = 4.dp
val small = 6.dp
@@ -40,9 +38,7 @@ object Dimensions {
val padding60 = 60.dp
val padding100 = 100.dp
- // ====================
- // CORNER RADIUS
- // ====================
+ // Corner Radius
val cornerRadiusSmall = 4.dp
val cornerRadiusMedium = 6.dp
val cornerRadiusRegular = 8.dp
@@ -53,9 +49,7 @@ object Dimensions {
val cornerRadiusBubble = 18.dp
val cornerRadiusModal = 20.dp
- // ====================
- // ICON SIZES
- // ====================
+ // Icon Sizes
val iconSmall = 8.dp
val iconRegular = 18.dp
val iconMedium = 28.dp
@@ -64,45 +58,43 @@ object Dimensions {
val iconXXLarge = 72.dp
val iconHuge = 80.dp
- // ====================
- // BUTTON HEIGHTS
- // ====================
+ // Button Heights
val buttonHeightSmall = 28.dp
val buttonHeightRegular = 44.dp
val buttonHeightLarge = 72.dp
- // ====================
- // FRAME SIZES
- // ====================
+ // Frame Sizes
val minFrameHeight = 150.dp
val maxFrameHeight = 150.dp
- // ====================
- // STROKE WIDTHS
- // ====================
+ // Stroke Widths
val strokeThin = 0.5.dp
val strokeRegular = 1.dp
val strokeMedium = 2.dp
- // ====================
- // SHADOW RADIUS
- // ====================
+ // Shadow Radius
val shadowSmall = 2.dp
val shadowMedium = 3.dp
val shadowLarge = 4.dp
val shadowXLarge = 10.dp
- // ====================
- // CHAT-SPECIFIC DIMENSIONS
- // ====================
+ // Chat-Specific Dimensions
- // Message Bubbles
+ // Message Bubbles — ChatGPT-style
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
+ val messageBubbleMinSpacing = padding60 // 60.dp (for alignment, legacy)
+ val messageSpacingBetween = xLarge // 20.dp
+ val messageMaxWidthFraction = 0.85f // fraction-based width for user messages
+
+ // Assistant message icon
+ val assistantIconSize = 20.dp
+ val assistantIconSpacing = 10.dp
+
+ // User bubble (simplified)
+ val userBubbleCornerRadius = cornerRadiusBubble // 18.dp
// Thinking Section
val thinkingSectionCornerRadius = mediumLarge // 12.dp
@@ -149,10 +141,11 @@ object Dimensions {
val toolbarButtonSpacing = smallMedium // 8.dp
val toolbarHeight = buttonHeightRegular // 44.dp
- // ====================
- // MAX WIDTHS
- // ====================
+ // Max Widths
val messageBubbleMaxWidth = 280.dp
val maxContentWidth = 700.dp
val contextMenuMaxWidth = 280.dp
+
+ // LoRA
+ val loraScaleSliderHeight = 32.dp
}
diff --git a/examples/ios/RunAnywhereAI/Package.resolved b/examples/ios/RunAnywhereAI/Package.resolved
index 773723953..a334e6c81 100644
--- a/examples/ios/RunAnywhereAI/Package.resolved
+++ b/examples/ios/RunAnywhereAI/Package.resolved
@@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
- "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88",
- "version" : "5.11.0"
+ "revision" : "3f99050e75bbc6fe71fc323adabb039756680016",
+ "version" : "5.11.1"
}
},
{
diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj
index dfc2ab58a..8ef7f36d2 100644
--- a/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj
+++ b/examples/ios/RunAnywhereAI/RunAnywhereAI.xcodeproj/project.pbxproj
@@ -14,6 +14,7 @@
54398D332F4939A7009D6B51 /* RunAnywhereActivityExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 54398D202F4939A7009D6B51 /* RunAnywhereActivityExtensionExtension.appex */; platformFilters = (ios, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
58ABEDD22ED16DA40058D033 /* RunAnywhereONNX in Frameworks */ = {isa = PBXBuildFile; productRef = 58ABEDD12ED16DA40058D033 /* RunAnywhereONNX */; };
58LLAMACPP12ED16DA40058D0 /* RunAnywhereLlamaCPP in Frameworks */ = {isa = PBXBuildFile; productRef = 58LLAMACPP02ED16DA40058D0 /* RunAnywhereLlamaCPP */; };
+ 58RAGBKND12ED16DA40058D03 /* RunAnywhereRAG in Frameworks */ = {isa = PBXBuildFile; productRef = 58RAGBKND02ED16DA40058D03 /* RunAnywhereRAG */; };
58WHISPERKIT1ED16DA40058D0 /* RunAnywhereWhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = 58WHISPERKIT0ED16DA40058D0 /* RunAnywhereWhisperKit */; };
RACACTIVITY01ACTIVITY01RAC /* DictationActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = RACACTIVITY02ACTIVITY02RAC /* DictationActivityAttributes.swift */; };
RACSHARED01RACSHARED01RACS /* SharedConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = RACSHARED02RACSHARED02RACS /* SharedConstants.swift */; };
@@ -168,6 +169,7 @@
58ABEDD22ED16DA40058D033 /* RunAnywhereONNX in Frameworks */,
541C59DA2E63772A00DD7839 /* RunAnywhere in Frameworks */,
58LLAMACPP12ED16DA40058D0 /* RunAnywhereLlamaCPP in Frameworks */,
+ 58RAGBKND12ED16DA40058D03 /* RunAnywhereRAG in Frameworks */,
58WHISPERKIT1ED16DA40058D0 /* RunAnywhereWhisperKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -308,6 +310,7 @@
541C59D92E63772A00DD7839 /* RunAnywhere */,
58ABEDD12ED16DA40058D033 /* RunAnywhereONNX */,
58LLAMACPP02ED16DA40058D0 /* RunAnywhereLlamaCPP */,
+ 58RAGBKND02ED16DA40058D03 /* RunAnywhereRAG */,
58WHISPERKIT0ED16DA40058D0 /* RunAnywhereWhisperKit */,
);
productName = RunAnywhereAI;
@@ -1079,6 +1082,11 @@
package = 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */;
productName = RunAnywhereLlamaCPP;
};
+ 58RAGBKND02ED16DA40058D03 /* RunAnywhereRAG */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */;
+ productName = RunAnywhereRAG;
+ };
58WHISPERKIT0ED16DA40058D0 /* RunAnywhereWhisperKit */ = {
isa = XCSwiftPackageProductDependency;
package = 58E021172E52A86000B722EF /* XCLocalSwiftPackageReference "../../.." */;
diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift
index cd20eb6b9..29ad27d00 100644
--- a/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift
+++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/App/RunAnywhereAIApp.swift
@@ -420,6 +420,10 @@ struct RunAnywhereAIApp: App {
logger.info("✅ Diffusion models registered (Apple Stable Diffusion / CoreML only)")
+ // Register LoRA adapters into SDK registry (same catalog as Android)
+ await LoRAAdapterCatalog.registerAll()
+ logger.info("✅ LoRA adapters registered (\(LoRAAdapterCatalog.adapters.count))")
+
logger.info("🎉 All modules and models registered")
}
}
diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/DemoLoRAAdapter.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/DemoLoRAAdapter.swift
index 100cdd3d5..230169972 100644
--- a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/DemoLoRAAdapter.swift
+++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/DemoLoRAAdapter.swift
@@ -2,119 +2,69 @@
// DemoLoRAAdapter.swift
// RunAnywhereAI
//
-// TODO: [Portal Integration] Remove this entire file once adapters are delivered OTA from portal.
+// Registers LoRA adapters into the SDK's global LoRA registry at startup.
+// Uses the SDK's LoraAdapterCatalogEntry — same type and registry that Android uses.
//
-// =========================================================================================
-// LoRA Demo Integration Guide
-// =========================================================================================
-//
-// WHAT THIS IS
-// ------------
-// This file provides a temporary, hardcoded LoRA adapter catalog so we can verify that the
-// full LoRA pipeline works end-to-end on iOS: download adapter OTA -> apply to model -> generate.
-// Once the RunAnywhere portal delivers adapter catalogs via its API, this file should be deleted
-// and replaced with the portal-provided data.
-//
-//
-// WHY QWEN 2.5 1.5B WAS CHOSEN
-// -----------------------------
-// LoRA adapters are architecture-specific: an adapter trained on Model A cannot be used with
-// Model B, even if they're the same parameter count. We needed a base model + GGUF LoRA adapter
-// pair that is publicly available and proven to work with llama.cpp.
-//
-// - SmolLM2 360M: No GGUF LoRA adapters exist anywhere (no one has published a fine-tune).
-// - Qwen 2.5 0.5B: No matching adapter (smallest ggml-org adapter is for 1.5B).
-// - LFM2 350M: No LoRA adapters exist. LFM2.5-1.2B adapters are architecturally incompatible
-// with the LFM2-1.2B-Tool model in the app (different model version).
-// - Qwen 2.5 1.5B: ggml-org (the llama.cpp team) publishes a tested, GGUF-format "abliterated"
-// LoRA adapter (~374MB). This is the smallest proven pair available.
-//
-// The Qwen 2.5 1.5B base model is registered in RunAnywhereAIApp.swift (~986MB Q4_K_M GGUF).
-//
-//
-// CONTEXT SIZE & MEMORY (C++ CHANGE)
-// ----------------------------------
-// Qwen 2.5 1.5B has 1.5B parameters and a 128K training context. The C++ llama.cpp backend
-// uses adaptive context sizing based on model size:
-//
-// >= 7B params -> 2048 context (fits ~6GB GPU memory)
-// >= 3B params -> 4096 context
-// >= 1B params -> 2048 context (** we added this tier **)
-// < 1B params -> 8192 context (tiny models, plenty of headroom)
-//
-// Without the 1-3B tier, the 1.5B model got 8192 context -> 4,748 MB compute buffer -> OOM crash.
-// Even at 4096, applying the F16 LoRA adapter pushed the compute buffer to 2,399 MB -> OOM.
-// At 2048 context, total runtime memory is ~2.5GB (weights + KV cache + LoRA + compute), which
-// fits on 6GB+ iPhones (iPhone 14 and newer).
-//
-// This change lives in: sdk/runanywhere-commons/src/backends/llamacpp/llamacpp_backend.cpp
-// (search for "Small-medium model detected")
-//
-//
-// LORA SCALE NOTE
-// ---------------
-// The demo adapter is F16 (full precision) applied to a Q4_K_M (4-bit quantized) base model.
-// At scale 1.0, this causes numerical instability -> gibberish output. Scale 0.3 is the tested
-// sweet spot: coherent output with observable behavior change. The UI slider still allows
-// adjustment (0.0 - 2.0) for experimentation.
-//
-//
-// PORTAL INTEGRATION CHECKLIST
-// ----------------------------
-// When the portal delivers LoRA adapters OTA, do the following:
-//
-// 1. DELETE this file (DemoLoRAAdapter.swift)
-// 2. DELETE the Qwen 2.5 1.5B model registration in RunAnywhereAIApp.swift
-// (search for "qwen2.5-1.5b-instruct-q4_k_m" and the TODO above it)
-// 3. In LLMViewModel.swift, REPLACE the demo adapter state & download logic
-// (search for "TODO: [Portal Integration]") with portal API calls
-// 4. In ChatInterfaceView.swift, UPDATE the "Available for This Model" section
-// in LoRAManagementSheetView to use portal-provided adapter data
-// (search for "TODO: [Portal Integration]")
-// 5. The SDK-level LoRA API (RunAnywhere+LoRA.swift, CppBridge+LLM.swift) stays unchanged --
-// it takes a local file path + scale, which is the same regardless of how the file got there
-//
-// =========================================================================================
+// TODO: [Portal Integration] Replace hardcoded adapters with portal-provided catalog.
import Foundation
+import RunAnywhere
-// MARK: - Demo LoRA Adapter Registry
+enum LoRAAdapterCatalog {
-/// Represents a pre-registered LoRA adapter available for OTA download.
-/// TODO: [Portal Integration] Replace with portal-provided adapter catalog model.
-struct DemoLoRAAdapter: Identifiable, Sendable {
- let id: String
- let name: String
- let description: String
- let downloadURL: URL
- let fileName: String
- let compatibleModelIds: Set
- let fileSize: Int64
- let defaultScale: Float
-
- var fileSizeFormatted: String {
- ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)
+ /// Register all known LoRA adapters into the SDK's C++ registry.
+ /// Call once at startup, after SDK initialization.
+ static func registerAll() async {
+ for entry in adapters {
+ do {
+ try await RunAnywhere.registerLoraAdapter(entry)
+ } catch {
+ print("[LoRA] Failed to register adapter \(entry.id): \(error)")
+ }
+ }
}
-}
-
-// MARK: - Demo Adapter Catalog
-/// TODO: [Portal Integration] Remove once adapters are delivered OTA from portal.
-enum DemoLoRAAdapterCatalog {
- static let adapters: [DemoLoRAAdapter] = [
- DemoLoRAAdapter(
- id: "qwen2.5-1.5b-abliterated-lora",
- name: "Abliterated (Uncensored)",
- description: "Removes refusal behavior from Qwen2.5-1.5B. From ggml-org.",
- downloadURL: URL(string: "https://huggingface.co/ggml-org/LoRA-Qwen2.5-1.5B-Instruct-abliterated-F16-GGUF/resolve/main/LoRA-Qwen2.5-1.5B-Instruct-abliterated-f16.gguf")!,
- fileName: "LoRA-Qwen2.5-1.5B-Instruct-abliterated-f16.gguf",
- compatibleModelIds: ["qwen2.5-1.5b-instruct-q4_k_m"],
- fileSize: 374_000_000,
- defaultScale: 0.3
- )
+ /// All hardcoded adapters (matches Android's ModelList.kt)
+ static let adapters: [LoraAdapterCatalogEntry] = [
+ LoraAdapterCatalogEntry(
+ id: "chat-assistant-lora",
+ name: "Chat Assistant",
+ description: "Enhances conversational chat ability",
+ downloadURL: URL(string: "https://huggingface.co/Void2377/Qwen/resolve/main/lora/chat_assistant-lora-Q8_0.gguf")!,
+ filename: "chat_assistant-lora-Q8_0.gguf",
+ compatibleModelIds: ["lfm2-350m-q4_k_m", "lfm2-350m-q8_0"],
+ fileSize: 690_176,
+ defaultScale: 1.0
+ ),
+ LoraAdapterCatalogEntry(
+ id: "summarizer-lora",
+ name: "Summarizer",
+ description: "Specialized for text summarization tasks",
+ downloadURL: URL(string: "https://huggingface.co/Void2377/Qwen/resolve/main/lora/summarizer-lora-Q8_0.gguf")!,
+ filename: "summarizer-lora-Q8_0.gguf",
+ compatibleModelIds: ["lfm2-350m-q4_k_m", "lfm2-350m-q8_0"],
+ fileSize: 690_176,
+ defaultScale: 1.0
+ ),
+ LoraAdapterCatalogEntry(
+ id: "translator-lora",
+ name: "Translator",
+ description: "Improves translation between languages",
+ downloadURL: URL(string: "https://huggingface.co/Void2377/Qwen/resolve/main/lora/translator-lora-Q8_0.gguf")!,
+ filename: "translator-lora-Q8_0.gguf",
+ compatibleModelIds: ["lfm2-350m-q4_k_m", "lfm2-350m-q8_0"],
+ fileSize: 690_176,
+ defaultScale: 1.0
+ ),
+ LoraAdapterCatalogEntry(
+ id: "sentiment-lora",
+ name: "Sentiment Analysis",
+ description: "Fine-tuned for sentiment analysis tasks",
+ downloadURL: URL(string: "https://huggingface.co/Void2377/Qwen/resolve/main/lora/sentiment-lora-Q8_0.gguf")!,
+ filename: "sentiment-lora-Q8_0.gguf",
+ compatibleModelIds: ["lfm2-350m-q4_k_m", "lfm2-350m-q8_0"],
+ fileSize: 690_176,
+ defaultScale: 1.0
+ ),
]
-
- static func adapters(forModelId modelId: String) -> [DemoLoRAAdapter] {
- adapters.filter { $0.compatibleModelIds.contains(modelId) }
- }
}
diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift
index 01f9b3965..cbeccfb89 100644
--- a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift
+++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift
@@ -38,10 +38,9 @@ final class LLMViewModel {
private(set) var loraAdapters: [LoRAAdapterInfo] = []
private(set) var isLoadingLoRA = false
- // MARK: - LoRA Adapter Download State
- // TODO: [Portal Integration] Remove demo adapter download state once portal delivers adapters OTA.
+ // MARK: - LoRA Adapter Catalog State
- private(set) var availableDemoAdapters: [DemoLoRAAdapter] = []
+ private(set) var availableAdapters: [LoraAdapterCatalogEntry] = []
private(set) var adapterDownloadProgress: [String: Double] = [:]
private(set) var downloadedAdapterPaths: [String: String] = [:]
private(set) var isDownloadingAdapter: [String: Bool] = [:]
@@ -284,7 +283,7 @@ final class LLMViewModel {
func clearChat() {
generationTask?.cancel()
-
+
// Generate smart title for the old conversation before creating new one
if let oldConversation = currentConversation,
oldConversation.messages.count >= 2 {
@@ -293,7 +292,7 @@ final class LLMViewModel {
await self.conversationStore.generateSmartTitleForConversation(conversationId)
}
}
-
+
messages.removeAll()
currentInput = ""
isGenerating = false
@@ -365,31 +364,28 @@ final class LLMViewModel {
}
}
- // MARK: - Demo LoRA Adapter Download
- // TODO: [Portal Integration] Remove demo adapter download logic once portal delivers adapters OTA.
+ // MARK: - LoRA Adapter Catalog & Download
- /// Refreshes the list of available demo adapters for the currently loaded model.
- func refreshAvailableDemoAdapters() {
+ /// Refreshes the list of available adapters for the currently loaded model from the SDK registry.
+ func refreshAvailableAdapters() async {
guard let modelId = ModelListViewModel.shared.currentModel?.id else {
- availableDemoAdapters = []
+ availableAdapters = []
return
}
- availableDemoAdapters = DemoLoRAAdapterCatalog.adapters(forModelId: modelId)
+ availableAdapters = await RunAnywhere.loraAdaptersForModel(modelId)
syncDownloadedAdapterPaths()
}
- /// Checks if a demo adapter's file already exists on disk.
- func isAdapterDownloaded(_ adapter: DemoLoRAAdapter) -> Bool {
+ func isAdapterDownloaded(_ adapter: LoraAdapterCatalogEntry) -> Bool {
downloadedAdapterPaths[adapter.id] != nil
}
- /// Returns the local file path for a downloaded adapter, or nil.
- func localPath(for adapter: DemoLoRAAdapter) -> String? {
+ func localPath(for adapter: LoraAdapterCatalogEntry) -> String? {
downloadedAdapterPaths[adapter.id]
}
- /// Downloads a demo adapter from its URL, then loads it.
- func downloadAndLoadAdapter(_ adapter: DemoLoRAAdapter, scale: Float) async {
+ /// Downloads a catalog adapter from its URL, then loads it.
+ func downloadAndLoadAdapter(_ adapter: LoraAdapterCatalogEntry, scale: Float) async {
guard isDownloadingAdapter[adapter.id] != true else { return }
isDownloadingAdapter[adapter.id] = true
@@ -413,11 +409,10 @@ final class LLMViewModel {
adapterDownloadProgress[adapter.id] = nil
}
- /// Downloads the adapter file to the LoRA directory.
- private func downloadAdapter(_ adapter: DemoLoRAAdapter) async throws -> String {
+ private func downloadAdapter(_ adapter: LoraAdapterCatalogEntry) async throws -> String {
let loraDir = Self.loraDownloadDirectory()
try FileManager.default.createDirectory(at: loraDir, withIntermediateDirectories: true)
- let destinationURL = loraDir.appendingPathComponent(adapter.fileName)
+ let destinationURL = loraDir.appendingPathComponent(adapter.filename)
if FileManager.default.fileExists(atPath: destinationURL.path) {
downloadedAdapterPaths[adapter.id] = destinationURL.path
@@ -441,11 +436,10 @@ final class LLMViewModel {
return destinationURL.path
}
- /// Scans the LoRA directory to populate downloadedAdapterPaths.
private func syncDownloadedAdapterPaths() {
let loraDir = Self.loraDownloadDirectory()
- for adapter in availableDemoAdapters {
- let path = loraDir.appendingPathComponent(adapter.fileName).path
+ for adapter in availableAdapters {
+ let path = loraDir.appendingPathComponent(adapter.filename).path
if FileManager.default.fileExists(atPath: path) {
downloadedAdapterPaths[adapter.id] = path
}
@@ -536,7 +530,7 @@ final class LLMViewModel {
self.messages.removeFirst()
}
self.addSystemMessage()
- self.refreshAvailableDemoAdapters()
+ Task { await self.refreshAvailableAdapters() }
}
} else {
await self.checkModelStatus()
diff --git a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift
index 1462fc0d5..c3866fc3c 100644
--- a/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift
+++ b/examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift
@@ -509,7 +509,7 @@ extension ChatInterfaceView {
var loraAdapterBadge: some View {
Button {
- viewModel.refreshAvailableDemoAdapters()
+ Task { await viewModel.refreshAvailableAdapters() }
showingLoRAManagement = true
} label: {
HStack(spacing: 6) {
@@ -528,7 +528,7 @@ extension ChatInterfaceView {
var loraAddButton: some View {
Button {
- viewModel.refreshAvailableDemoAdapters()
+ Task { await viewModel.refreshAvailableAdapters() }
showingLoRAManagement = true
} label: {
HStack(spacing: 4) {
@@ -646,14 +646,13 @@ private struct LoRAManagementSheetView: View {
}
}
- // MARK: - Available Adapters (OTA Download)
- // TODO: [Portal Integration] Replace demo adapter section with portal-provided adapter catalog.
+ // MARK: - Available Adapters (from SDK Registry)
@ViewBuilder
private var availableAdaptersSection: some View {
- if !viewModel.availableDemoAdapters.isEmpty {
+ if !viewModel.availableAdapters.isEmpty {
Section {
- ForEach(viewModel.availableDemoAdapters) { adapter in
+ ForEach(viewModel.availableAdapters, id: \.id) { adapter in
availableAdapterRow(adapter)
}
} header: {
@@ -664,7 +663,7 @@ private struct LoRAManagementSheetView: View {
}
}
- private func availableAdapterRow(_ adapter: DemoLoRAAdapter) -> some View {
+ private func availableAdapterRow(_ adapter: LoraAdapterCatalogEntry) -> some View {
let isDownloaded = viewModel.isAdapterDownloaded(adapter)
let isDownloading = viewModel.isDownloadingAdapter[adapter.id] == true
let progress = viewModel.adapterDownloadProgress[adapter.id] ?? 0.0
@@ -672,6 +671,7 @@ private struct LoRAManagementSheetView: View {
let isAlreadyApplied = viewModel.loraAdapters.contains {
$0.path == viewModel.localPath(for: adapter)
}
+ let fileSizeText = ByteCountFormatter.string(fromByteCount: adapter.fileSize, countStyle: .file)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
@@ -679,10 +679,10 @@ private struct LoRAManagementSheetView: View {
Text(adapter.name)
.font(.subheadline)
.fontWeight(.medium)
- Text(adapter.description)
+ Text(adapter.adapterDescription)
.font(.caption)
.foregroundColor(.secondary)
- Text(adapter.fileSizeFormatted)
+ Text(fileSizeText)
.font(.caption2)
.foregroundColor(.secondary)
}
diff --git a/sdk/runanywhere-commons/CMakeLists.txt b/sdk/runanywhere-commons/CMakeLists.txt
index 69b16bb18..4308620ba 100644
--- a/sdk/runanywhere-commons/CMakeLists.txt
+++ b/sdk/runanywhere-commons/CMakeLists.txt
@@ -206,6 +206,7 @@ set(RAC_INFRASTRUCTURE_SOURCES
src/infrastructure/registry/service_registry.cpp
src/infrastructure/download/download_manager.cpp
src/infrastructure/model_management/model_registry.cpp
+ src/infrastructure/model_management/lora_registry.cpp
src/infrastructure/model_management/model_types.cpp
src/infrastructure/model_management/model_paths.cpp
src/infrastructure/model_management/model_strategy.cpp
diff --git a/sdk/runanywhere-commons/exports/RACommons.exports b/sdk/runanywhere-commons/exports/RACommons.exports
index 97d50563d..703aa6424 100644
--- a/sdk/runanywhere-commons/exports/RACommons.exports
+++ b/sdk/runanywhere-commons/exports/RACommons.exports
@@ -202,6 +202,30 @@ _rac_llm_component_load_model
_rac_llm_component_supports_streaming
_rac_llm_component_unload
+# LLM Component - LoRA
+_rac_llm_component_load_lora
+_rac_llm_component_remove_lora
+_rac_llm_component_clear_lora
+_rac_llm_component_get_lora_info
+_rac_llm_component_check_lora_compat
+
+# LoRA Registry
+_rac_lora_registry_create
+_rac_lora_registry_destroy
+_rac_lora_registry_register
+_rac_lora_registry_remove
+_rac_lora_registry_get_all
+_rac_lora_registry_get_for_model
+_rac_lora_registry_get
+_rac_lora_entry_free
+_rac_lora_entry_array_free
+_rac_lora_entry_copy
+
+# LoRA Registry - Global convenience API
+_rac_get_lora_registry
+_rac_register_lora
+_rac_get_lora_for_model
+
# LLM Analytics
_rac_llm_analytics_complete_generation
_rac_llm_analytics_create
diff --git a/sdk/runanywhere-commons/include/rac/core/rac_core.h b/sdk/runanywhere-commons/include/rac/core/rac_core.h
index 854bfe062..46e2479a6 100644
--- a/sdk/runanywhere-commons/include/rac/core/rac_core.h
+++ b/sdk/runanywhere-commons/include/rac/core/rac_core.h
@@ -12,6 +12,7 @@
#include "rac/core/rac_error.h"
#include "rac/core/rac_types.h"
#include "rac/infrastructure/model_management/rac_model_types.h"
+#include "rac/infrastructure/model_management/rac_lora_registry.h"
#include "rac/infrastructure/network/rac_environment.h"
#ifdef __cplusplus
@@ -335,6 +336,37 @@ RAC_API rac_result_t rac_get_model(const char* model_id, struct rac_model_info**
*/
RAC_API rac_result_t rac_get_model_by_path(const char* local_path, struct rac_model_info** out_model);
+// =============================================================================
+// GLOBAL LORA REGISTRY API
+// =============================================================================
+
+/**
+ * @brief Get the global LoRA adapter registry singleton
+ *
+ * The registry is lazily created on first access and lives for the process lifetime.
+ *
+ * @return Handle to the global registry (never NULL after first successful call)
+ */
+RAC_API struct rac_lora_registry* rac_get_lora_registry(void);
+
+/**
+ * @brief Register a LoRA adapter in the global registry
+ * @param entry Adapter entry to register (deep-copied internally)
+ * @return RAC_SUCCESS or error code
+ */
+RAC_API rac_result_t rac_register_lora(const struct rac_lora_entry* entry);
+
+/**
+ * @brief Query the global registry for adapters compatible with a model
+ * @param model_id Model ID to match
+ * @param out_entries Output: array of matching entries (caller must free with rac_lora_entry_array_free)
+ * @param out_count Output: number of matching entries
+ * @return RAC_SUCCESS or error code
+ */
+RAC_API rac_result_t rac_get_lora_for_model(const char* model_id,
+ struct rac_lora_entry*** out_entries,
+ size_t* out_count);
+
#ifdef __cplusplus
}
#endif
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
index 35e4e92ba..bae475b6e 100644
--- a/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_component.h
+++ b/sdk/runanywhere-commons/include/rac/features/llm/rac_llm_component.h
@@ -263,6 +263,21 @@ RAC_API rac_result_t rac_llm_component_clear_lora(rac_handle_t handle);
RAC_API rac_result_t rac_llm_component_get_lora_info(rac_handle_t handle,
char** out_json);
+/**
+ * @brief Check if the current backend supports LoRA adapters
+ *
+ * Verifies that a model is loaded and the active backend exposes LoRA operations.
+ * This is a lightweight pre-check; actual file validation occurs during load.
+ *
+ * @param handle Component handle
+ * @param adapter_path Path to the LoRA adapter GGUF file (must be non-empty)
+ * @param out_error Output: error message if incompatible (caller must free with rac_free), NULL if compatible
+ * @return RAC_SUCCESS if the backend supports LoRA, error code otherwise
+ */
+RAC_API rac_result_t rac_llm_component_check_lora_compat(rac_handle_t handle,
+ const char* adapter_path,
+ char** out_error);
+
// =============================================================================
// DESTRUCTION
// =============================================================================
diff --git a/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_lora_registry.h b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_lora_registry.h
new file mode 100644
index 000000000..38e60ca37
--- /dev/null
+++ b/sdk/runanywhere-commons/include/rac/infrastructure/model_management/rac_lora_registry.h
@@ -0,0 +1,149 @@
+/**
+ * @file rac_lora_registry.h
+ * @brief LoRA Adapter Registry - In-Memory LoRA Adapter Metadata Management
+ *
+ * Provides a centralized registry for LoRA adapter metadata across all SDKs.
+ * Follows the same pattern as rac_model_registry.h.
+ *
+ * Apps register LoRA adapters at startup with explicit compatible model IDs.
+ * SDKs can then query "which adapters work with this model" without
+ * reinventing detection logic per platform.
+ *
+ * NOTE: This registry is metadata only. The runtime compat check
+ * (rac_llm_component_check_lora_compat) remains the safety net at load time.
+ */
+
+#ifndef RAC_LORA_REGISTRY_H
+#define RAC_LORA_REGISTRY_H
+
+#include "rac/core/rac_error.h"
+#include "rac/core/rac_types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// TYPES
+
+typedef struct rac_lora_entry {
+ char* id; // Unique adapter identifier
+ char* name; // Human-readable display name
+ char* description; // Short description of what this adapter does
+ char* download_url; // Direct download URL (.gguf file)
+ char* filename; // Filename to save as on disk
+ char** compatible_model_ids; // Explicit list of compatible base model IDs
+ size_t compatible_model_count;
+ int64_t file_size; // File size in bytes (0 if unknown)
+ float default_scale; // Recommended LoRA scale (e.g. 0.3)
+} rac_lora_entry_t;
+
+typedef struct rac_lora_registry* rac_lora_registry_handle_t;
+
+// LIFECYCLE
+
+/**
+ * @brief Create a new LoRA adapter registry
+ * @param out_handle Output: handle to the newly created registry
+ * @return RAC_SUCCESS, RAC_ERROR_INVALID_ARGUMENT (NULL out_handle),
+ * or RAC_ERROR_OUT_OF_MEMORY
+ */
+RAC_API rac_result_t rac_lora_registry_create(rac_lora_registry_handle_t* out_handle);
+
+/**
+ * @brief Destroy a LoRA adapter registry and free all entries
+ * @param handle Registry handle (NULL is a no-op)
+ */
+RAC_API void rac_lora_registry_destroy(rac_lora_registry_handle_t handle);
+
+// REGISTRATION
+
+/**
+ * @brief Register a LoRA adapter entry in the registry
+ *
+ * The entry is deep-copied; the caller retains ownership of the original.
+ * If an entry with the same id already exists, it is replaced.
+ *
+ * @param handle Registry handle
+ * @param entry Adapter entry to register (must have a non-NULL id)
+ * @return RAC_SUCCESS, RAC_ERROR_INVALID_ARGUMENT (NULL handle/entry/id),
+ * or RAC_ERROR_OUT_OF_MEMORY
+ */
+RAC_API rac_result_t rac_lora_registry_register(rac_lora_registry_handle_t handle,
+ const rac_lora_entry_t* entry);
+
+/**
+ * @brief Remove a LoRA adapter entry from the registry by id
+ * @param handle Registry handle
+ * @param adapter_id ID of the adapter to remove
+ * @return RAC_SUCCESS or RAC_ERROR_NOT_FOUND
+ */
+RAC_API rac_result_t rac_lora_registry_remove(rac_lora_registry_handle_t handle,
+ const char* adapter_id);
+
+// QUERIES
+
+/**
+ * @brief Get all registered LoRA adapter entries
+ * @param handle Registry handle
+ * @param out_entries Output: array of deep-copied entries (caller must free with rac_lora_entry_array_free)
+ * @param out_count Output: number of entries
+ * @return RAC_SUCCESS, RAC_ERROR_INVALID_ARGUMENT (NULL params),
+ * or RAC_ERROR_OUT_OF_MEMORY
+ */
+RAC_API rac_result_t rac_lora_registry_get_all(rac_lora_registry_handle_t handle,
+ rac_lora_entry_t*** out_entries,
+ size_t* out_count);
+
+/**
+ * @brief Get LoRA adapter entries compatible with a specific model
+ * @param handle Registry handle
+ * @param model_id Model ID to match against each entry's compatible_model_ids
+ * @param out_entries Output: array of matching deep-copied entries (caller must free with rac_lora_entry_array_free)
+ * @param out_count Output: number of matching entries
+ * @return RAC_SUCCESS, RAC_ERROR_INVALID_ARGUMENT (NULL params),
+ * or RAC_ERROR_OUT_OF_MEMORY
+ */
+RAC_API rac_result_t rac_lora_registry_get_for_model(rac_lora_registry_handle_t handle,
+ const char* model_id,
+ rac_lora_entry_t*** out_entries,
+ size_t* out_count);
+
+/**
+ * @brief Get a single LoRA adapter entry by id
+ * @param handle Registry handle
+ * @param adapter_id ID of the adapter to look up
+ * @param out_entry Output: deep-copied entry (caller must free with rac_lora_entry_free)
+ * @return RAC_SUCCESS, RAC_ERROR_INVALID_ARGUMENT (NULL params),
+ * RAC_ERROR_NOT_FOUND, or RAC_ERROR_OUT_OF_MEMORY
+ */
+RAC_API rac_result_t rac_lora_registry_get(rac_lora_registry_handle_t handle,
+ const char* adapter_id,
+ rac_lora_entry_t** out_entry);
+
+// MEMORY
+
+/**
+ * @brief Free a single LoRA entry and all its owned strings
+ * @param entry Entry to free (NULL is a no-op)
+ */
+RAC_API void rac_lora_entry_free(rac_lora_entry_t* entry);
+
+/**
+ * @brief Free an array of LoRA entries returned by get_all / get_for_model
+ * @param entries Array of entry pointers
+ * @param count Number of entries in the array
+ */
+RAC_API void rac_lora_entry_array_free(rac_lora_entry_t** entries, size_t count);
+
+/**
+ * @brief Deep-copy a LoRA entry
+ * @param entry Entry to copy
+ * @return Newly allocated copy (caller must free with rac_lora_entry_free), or NULL on allocation failure
+ */
+RAC_API rac_lora_entry_t* rac_lora_entry_copy(const rac_lora_entry_t* entry);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* RAC_LORA_REGISTRY_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
index 3455faa2e..ffb2b4531 100644
--- 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
@@ -248,6 +248,9 @@ typedef struct rac_model_info {
/** Whether model supports thinking/reasoning */
rac_bool_t supports_thinking;
+ /** Whether model supports LoRA adapters */
+ rac_bool_t supports_lora;
+
/** Tags (NULL-terminated array of strings, can be NULL) */
char** tags;
size_t tag_count;
diff --git a/sdk/runanywhere-commons/scripts/build-ios.sh b/sdk/runanywhere-commons/scripts/build-ios.sh
index 237573343..d69c202b9 100755
--- a/sdk/runanywhere-commons/scripts/build-ios.sh
+++ b/sdk/runanywhere-commons/scripts/build-ios.sh
@@ -351,12 +351,13 @@ create_xcframework() {
local PLATFORM_DIR="${BUILD_DIR}/${PLATFORM}"
local FRAMEWORK_DIR="${PLATFORM_DIR}/${FRAMEWORK_NAME}.framework"
+ rm -rf "${FRAMEWORK_DIR}"
mkdir -p "${FRAMEWORK_DIR}/Headers"
mkdir -p "${FRAMEWORK_DIR}/Modules"
# Find the library (try multiple locations)
local LIB_PATH="${PLATFORM_DIR}/lib${LIB_NAME}.a"
-
+
# Try Xcode generator output paths
if [[ ! -f "${LIB_PATH}" ]]; then
if [[ "$PLATFORM" == "OS" ]]; then
@@ -365,7 +366,7 @@ create_xcframework() {
LIB_PATH="${PLATFORM_DIR}/Release-iphonesimulator/lib${LIB_NAME}.a"
fi
fi
-
+
# Try backend-specific paths
[[ ! -f "${LIB_PATH}" ]] && LIB_PATH="${PLATFORM_DIR}/src/backends/${BUILD_BACKEND}/lib${LIB_NAME}.a"
@@ -376,11 +377,11 @@ create_xcframework() {
cp "${LIB_PATH}" "${FRAMEWORK_DIR}/${FRAMEWORK_NAME}"
- # Copy headers
+ # Copy headers (flatten rac/subdir/header.h paths to flat includes)
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' \
+ sed -e 's|#include "rac/[^"]*\/\([^"]*\)"|#include "\1"|g' \
"$header" > "${FRAMEWORK_DIR}/Headers/${filename}"
done
else
@@ -433,7 +434,7 @@ EOF
done
# SIMULATOR already contains universal binary (arm64 + x86_64)
- # No need to create fat binary as both SIMULATORARM64 and SIMULATOR have arm64
+ local SIM_FAT="${BUILD_DIR}/SIMULATOR"
# Create XCFramework using library format (prevents SPM from embedding static libs)
local XCFW_PATH="${DIST_DIR}/${FRAMEWORK_NAME}.xcframework"
@@ -486,6 +487,7 @@ create_backend_xcframework() {
local PLATFORM_DIR="${BUILD_DIR}/${PLATFORM}"
local FRAMEWORK_DIR="${PLATFORM_DIR}/${FRAMEWORK_NAME}.framework"
+ rm -rf "${FRAMEWORK_DIR}"
mkdir -p "${FRAMEWORK_DIR}/Headers"
mkdir -p "${FRAMEWORK_DIR}/Modules"
@@ -500,7 +502,7 @@ create_backend_xcframework() {
else
XCODE_SUBDIR="Release-iphonesimulator"
fi
-
+
for possible_path in \
"${PLATFORM_DIR}/src/backends/${BACKEND_NAME}/librac_backend_${BACKEND_NAME}.a" \
"${PLATFORM_DIR}/${XCODE_SUBDIR}/librac_backend_${BACKEND_NAME}.a" \
@@ -568,47 +570,6 @@ create_backend_xcframework() {
fi
done
fi
-
-
- elif [[ "$BACKEND_NAME" == "rag" ]]; then
- # RAG backend depends on:
- # 1. Sherpa-ONNX
- # 2. ONNX Runtime (required for Ort* symbols)
-
- # -------------------------------
- # 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
-
- 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
-
- # -------------------------------
- # Bundle ONNX Runtime
- # -------------------------------
- local ONNX_XCFW="${PROJECT_ROOT}/third_party/onnxruntime-ios/onnxruntime.xcframework"
- local ONNX_ARCH="$SHERPA_ARCH"
-
- for possible in \
- "${ONNX_XCFW}/${ONNX_ARCH}/libonnxruntime.a" \
- "${ONNX_XCFW}/${ONNX_ARCH}/onnxruntime.framework/onnxruntime"; do
- if [[ -f "$possible" ]]; then
- LIBS_TO_BUNDLE+=("$possible")
- break
- fi
- done
fi
# Bundle all libraries
@@ -665,7 +626,7 @@ EOF
fi
# SIMULATOR already contains universal binary (arm64 + x86_64)
- # No need to create fat binary as both SIMULATORARM64 and SIMULATOR have arm64
+ local SIM_FAT="${BUILD_DIR}/SIMULATOR"
# Create XCFramework using library format (prevents SPM from embedding static libs)
local XCFW_PATH="${DIST_DIR}/${FRAMEWORK_NAME}.xcframework"
diff --git a/sdk/runanywhere-commons/src/core/rac_core.cpp b/sdk/runanywhere-commons/src/core/rac_core.cpp
index bd73f3958..764bf39b5 100644
--- a/sdk/runanywhere-commons/src/core/rac_core.cpp
+++ b/sdk/runanywhere-commons/src/core/rac_core.cpp
@@ -17,6 +17,7 @@
#include "rac/core/rac_structured_error.h"
#include "rac/infrastructure/device/rac_device_manager.h"
#include "rac/infrastructure/model_management/rac_model_registry.h"
+#include "rac/infrastructure/model_management/rac_lora_registry.h"
#if !defined(RAC_PLATFORM_ANDROID)
#include "rac/features/diffusion/rac_diffusion_model_registry.h"
#endif
@@ -35,6 +36,10 @@ static std::string s_log_tag = "RAC";
static rac_model_registry_handle_t s_model_registry = nullptr;
static std::mutex s_model_registry_mutex;
+// Global LoRA registry
+static rac_lora_registry_handle_t s_lora_registry = nullptr;
+static std::mutex s_lora_registry_mutex;
+
// Version info
static const char* s_version_string = "1.0.0";
static const rac_version_t s_version = {
@@ -288,4 +293,34 @@ rac_bool_t rac_framework_is_platform_service(rac_inference_framework_t framework
}
}
+// =============================================================================
+// GLOBAL LORA REGISTRY
+// =============================================================================
+
+rac_lora_registry_handle_t rac_get_lora_registry(void) {
+ std::lock_guard lock(s_lora_registry_mutex);
+ if (s_lora_registry == nullptr) {
+ rac_result_t result = rac_lora_registry_create(&s_lora_registry);
+ if (result != RAC_SUCCESS) {
+ RAC_LOG_ERROR("RAC.Core", "Failed to create global LoRA registry");
+ return nullptr;
+ }
+ RAC_LOG_INFO("RAC.Core", "Global LoRA registry created");
+ }
+ return s_lora_registry;
+}
+
+rac_result_t rac_register_lora(const rac_lora_entry_t* entry) {
+ rac_lora_registry_handle_t registry = rac_get_lora_registry();
+ if (registry == nullptr) return RAC_ERROR_NOT_INITIALIZED;
+ return rac_lora_registry_register(registry, entry);
+}
+
+rac_result_t rac_get_lora_for_model(const char* model_id, rac_lora_entry_t*** out_entries,
+ size_t* out_count) {
+ rac_lora_registry_handle_t registry = rac_get_lora_registry();
+ if (registry == nullptr) return RAC_ERROR_NOT_INITIALIZED;
+ return rac_lora_registry_get_for_model(registry, model_id, out_entries, out_count);
+}
+
} // extern "C"
diff --git a/sdk/runanywhere-commons/src/features/llm/llm_component.cpp b/sdk/runanywhere-commons/src/features/llm/llm_component.cpp
index f971b43f3..d09f739ea 100644
--- a/sdk/runanywhere-commons/src/features/llm/llm_component.cpp
+++ b/sdk/runanywhere-commons/src/features/llm/llm_component.cpp
@@ -782,7 +782,7 @@ extern "C" rac_result_t rac_llm_component_load_lora(rac_handle_t handle,
float scale) {
if (!handle)
return RAC_ERROR_INVALID_HANDLE;
- if (!adapter_path)
+ if (!adapter_path || adapter_path[0] == '\0')
return RAC_ERROR_INVALID_ARGUMENT;
auto* component = reinterpret_cast(handle);
@@ -805,7 +805,7 @@ extern "C" rac_result_t rac_llm_component_remove_lora(rac_handle_t handle,
const char* adapter_path) {
if (!handle)
return RAC_ERROR_INVALID_HANDLE;
- if (!adapter_path)
+ if (!adapter_path || adapter_path[0] == '\0')
return RAC_ERROR_INVALID_ARGUMENT;
auto* component = reinterpret_cast(handle);
@@ -863,6 +863,42 @@ extern "C" rac_result_t rac_llm_component_get_lora_info(rac_handle_t handle,
return llm_service->ops->get_lora_info(llm_service->impl, out_json);
}
+extern "C" rac_result_t rac_llm_component_check_lora_compat(rac_handle_t handle,
+ const char* adapter_path,
+ char** out_error) {
+ if (!handle)
+ return RAC_ERROR_INVALID_HANDLE;
+ if (!adapter_path || !out_error)
+ return RAC_ERROR_INVALID_ARGUMENT;
+
+ *out_error = nullptr;
+
+ auto* component = reinterpret_cast(handle);
+ std::lock_guard lock(component->mtx);
+
+ rac_handle_t service = rac_lifecycle_get_service(component->lifecycle);
+ if (!service) {
+ *out_error = rac_strdup("No model loaded");
+ return RAC_ERROR_COMPONENT_NOT_READY;
+ }
+
+ // Check if the adapter file path is non-empty
+ if (strlen(adapter_path) == 0) {
+ *out_error = rac_strdup("Empty adapter path");
+ return RAC_ERROR_INVALID_ARGUMENT;
+ }
+
+ // Basic pre-check: verify the backend supports LoRA at all
+ auto* llm_service = reinterpret_cast(service);
+ if (!llm_service->ops || !llm_service->ops->load_lora) {
+ *out_error = rac_strdup("Backend does not support LoRA adapters");
+ return RAC_ERROR_NOT_SUPPORTED;
+ }
+
+ // Adapter path and backend both valid - considered compatible
+ return RAC_SUCCESS;
+}
+
// =============================================================================
// STATE QUERY API
// =============================================================================
diff --git a/sdk/runanywhere-commons/src/infrastructure/model_management/lora_registry.cpp b/sdk/runanywhere-commons/src/infrastructure/model_management/lora_registry.cpp
new file mode 100644
index 000000000..8c5f562ac
--- /dev/null
+++ b/sdk/runanywhere-commons/src/infrastructure/model_management/lora_registry.cpp
@@ -0,0 +1,210 @@
+/**
+ * @file lora_registry.cpp
+ * @brief RunAnywhere Commons - LoRA Adapter Registry Implementation
+ *
+ * In-memory LoRA adapter metadata store.
+ * Follows the same pattern as model_registry.cpp.
+ */
+
+#include
+#include
+#include