Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Requested only when the user chooses Connect in the model picker. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<!-- Model downloads run in a foreground service so multi-GB NPU bundles keep
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.runanywhere.runanywhereai.ui.connect

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.runanywhere.sdk.public.connect.ConnectHost
import com.runanywhere.sdk.public.connect.ConnectSession
import kotlinx.coroutines.launch

/** App-scoped owner for the opt-in Android Connect client session. */
class ConnectClientViewModel(application: Application) : AndroidViewModel(application) {
val session = ConnectSession(application)
val state = session.state

fun startDiscovery() {
viewModelScope.launch { runCatching { session.startBrowsing() } }
}

fun connect(host: ConnectHost, onConnected: () -> Unit = {}) {
viewModelScope.launch {
if (runCatching { session.connect(host) }.isSuccess) onConnected()
}
}

fun disconnect() {
session.disconnect()
}

override fun onCleared() {
session.stop()
super.onCleared()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package com.runanywhere.runanywhereai.ui.connect

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.runanywhere.runanywhereai.ui.theme.icons.RACIcons
import com.runanywhere.runanywhereai.ui.theme.primaryGreen
import com.runanywhere.sdk.public.connect.ConnectState
import com.runanywhere.sdk.public.connect.ConnectStatus
import kotlinx.coroutines.delay

@Composable
fun ConnectStatusBanner(
state: ConnectState,
onConnect: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val key = state.bannerKey()
var dismissedKey by remember { mutableStateOf<String?>(null) }

LaunchedEffect(key) {
if (key == null) return@LaunchedEffect
dismissedKey = null
if (state.status == ConnectStatus.CONNECTED) {
delay(6_000)
if (key == state.bannerKey()) dismissedKey = key
}
}

AnimatedVisibility(
visible = key != null && dismissedKey != key,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
modifier = modifier,
) {
val presentation = state.presentation()
Surface(
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
tonalElevation = 6.dp,
shadowElevation = 10.dp,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 680.dp)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(36.dp)
.background(presentation.tint.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = presentation.icon,
contentDescription = null,
tint = presentation.tint,
modifier = Modifier.size(20.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = presentation.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = presentation.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when (state.status) {
ConnectStatus.IDLE, ConnectStatus.DISCOVERING -> if (state.availableHosts.isNotEmpty()) {
RoundAction(RACIcons.Outline.Link, "Connect to host", onConnect)
}
ConnectStatus.CONNECTING -> CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
ConnectStatus.DISCONNECTED, ConnectStatus.FAILED -> {
RoundAction(RACIcons.Outline.Refresh, "Find a host again", onRetry, filled = false)
}
ConnectStatus.CONNECTED -> Unit
}
IconButton(onClick = { dismissedKey = key }, modifier = Modifier.size(36.dp)) {
Icon(RACIcons.Outline.Close, contentDescription = "Dismiss Connect status")
}
}
}
}
}

@Composable
private fun RoundAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
onClick: () -> Unit,
filled: Boolean = true,
) {
val tint = MaterialTheme.colorScheme.primary
IconButton(
onClick = onClick,
modifier = Modifier
.size(36.dp)
.background(if (filled) tint else tint.copy(alpha = 0.12f), CircleShape),
) {
Icon(icon, contentDescription = description, tint = if (filled) Color.White else tint)
}
}

private data class BannerPresentation(
val title: String,
val subtitle: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val tint: Color,
)

@Composable
private fun ConnectState.presentation(): BannerPresentation = when (status) {
ConnectStatus.CONNECTED -> BannerPresentation(
title = activeHost?.displayName.shortened(42, "Connected Host"),
subtitle = activeModel?.displayName.shortened(54, "Using a hosted model"),
icon = RACIcons.Outline.Check,
tint = primaryGreen,
)
ConnectStatus.CONNECTING -> BannerPresentation(
title = "Connecting to ${connectingHost?.displayName.shortened(28, "host")}",
subtitle = "Checking the selected model",
icon = RACIcons.Outline.Refresh,
tint = MaterialTheme.colorScheme.primary,
)
ConnectStatus.DISCONNECTED -> BannerPresentation(
title = "Connection lost",
subtitle = message.shortened(72, "The host stopped or left the network"),
icon = RACIcons.Outline.AlertTriangle,
tint = MaterialTheme.colorScheme.error,
)
ConnectStatus.FAILED -> BannerPresentation(
title = "Couldn't connect",
subtitle = message.shortened(72, "Check the host and your local network"),
icon = RACIcons.Outline.AlertTriangle,
tint = MaterialTheme.colorScheme.error,
)
else -> BannerPresentation(
title = availableHosts.firstOrNull()?.displayName.shortened(42, "Host available"),
subtitle = "Language model available on your local network",
icon = RACIcons.Outline.Desktop,
tint = MaterialTheme.colorScheme.primary,
)
}

private fun ConnectState.bannerKey(): String? = when (status) {
ConnectStatus.CONNECTING -> "connecting:${connectingHost?.id}"
ConnectStatus.CONNECTED -> "connected:${activeHost?.id}:${activeModel?.id}"
ConnectStatus.DISCONNECTED -> "disconnected:${lastDisconnectedHost?.id}:$message"
ConnectStatus.FAILED -> "failed:$message"
ConnectStatus.IDLE, ConnectStatus.DISCOVERING -> availableHosts.firstOrNull()?.let { "available:${it.id}" }
}

private fun String?.shortened(limit: Int, fallback: String): String {
val value = this?.trim().orEmpty().ifBlank { fallback }
return if (value.length <= limit) value else value.take(limit - 1) + "…"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ import com.runanywhere.runanywhereai.ui.theme.LocalDimens
import com.runanywhere.runanywhereai.ui.theme.icons.RACIcons
import com.runanywhere.runanywhereai.ui.theme.primaryGreen
import com.runanywhere.sdk.public.types.RAModelInfo
import com.runanywhere.sdk.public.connect.ConnectModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatTopBar(
model: RAModelInfo?,
hostedModel: ConnectModel?,
conversationModelName: String?,
generating: Boolean,
loraActive: Boolean,
Expand Down Expand Up @@ -76,6 +78,7 @@ fun ChatTopBar(
title = {
ModelCard(
model = model,
hostedModel = hostedModel,
fallbackModelName = conversationModelName,
generating = generating,
onClick = onModelClick,
Expand All @@ -88,7 +91,7 @@ fun ChatTopBar(
IconButton(onClick = onNewChat) {
Icon(RACIcons.Outline.Plus, contentDescription = "New chat")
}
if (hasMessages || model?.supports_lora == true) {
if (hasMessages || (hostedModel == null && model?.supports_lora == true)) {
IconButton(onClick = { overflowExpanded = true }) {
Icon(RACIcons.Outline.DotsVertical, contentDescription = "More chat actions")
}
Expand All @@ -106,7 +109,7 @@ fun ChatTopBar(
},
)
}
if (model?.supports_lora == true) {
if (hostedModel == null && model?.supports_lora == true) {
DropdownMenuItem(
text = { Text(if (loraActive) "Adapters active" else "Adapters") },
leadingIcon = { Icon(RACIcons.Outline.Adjustments, contentDescription = null) },
Expand All @@ -125,28 +128,32 @@ fun ChatTopBar(
@Composable
private fun ModelCard(
model: RAModelInfo?,
hostedModel: ConnectModel?,
fallbackModelName: String?,
generating: Boolean,
onClick: () -> Unit,
) {
val dimens = LocalDimens.current
val brand = model?.brand()
val brand = if (hostedModel == null) model?.brand() else null
// Mirrors iOS loadConversation restore: with no model loaded, the
// conversation's recorded model is shown as a preselection (not loaded).
val statusText = when {
generating -> "Generating…"
hostedModel != null -> "Ready on host"
model != null -> "Ready"
fallbackModelName != null -> "Not loaded"
else -> "Tap to choose"
}
val backendStatusText = if (model != null && !generating) {
val backendStatusText = if (hostedModel != null && !generating) {
"Host · $statusText"
} else if (model != null && !generating) {
"${model.framework.shortLabel()} · $statusText"
} else {
statusText
}
val dotColor = when {
generating -> MaterialTheme.colorScheme.primary
model != null -> primaryGreen
hostedModel != null || model != null -> primaryGreen
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val dotAlpha = if (generating) {
Expand All @@ -168,15 +175,15 @@ private fun ModelCard(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = brand?.icon ?: RACIcons.Outline.Bolt,
imageVector = if (hostedModel != null) RACIcons.Outline.Desktop else brand?.icon ?: RACIcons.Outline.Bolt,
contentDescription = "Model",
tint = brand?.color ?: MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(dimens.spacingSm),
)

Column(modifier = Modifier.padding(end = dimens.spacingSm)) {
Text(
text = model?.name ?: fallbackModelName ?: "Select Model",
text = hostedModel?.displayName ?: model?.name ?: fallbackModelName ?: "Select Model",
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.titleMedium,
Expand Down
Loading
Loading