feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "rs.rhema.phone"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "rs.rhema.phone"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// Debug builds must install over each other, so the signing key
|
||||
// may not depend on the machine that ran the build: CI artifacts
|
||||
// share the identity of every local build via the committed
|
||||
// android/debug.keystore. A debug key guards nothing — this is
|
||||
// the conventional Android Debug identity, password "android" —
|
||||
// while a stable signature is what makes the artifacts updatable.
|
||||
getByName("debug") {
|
||||
storeFile = rootProject.file("debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
|
||||
// The release key anchors the future edge channel: whatever is
|
||||
// signed with it can silently update an install that trusts the
|
||||
// channel, so it must not travel with the source. It exists only
|
||||
// as a CI secret (see .gitea/workflows/android.yml); blank
|
||||
// environment — local builds, fork PRs — leaves release unsigned.
|
||||
create("release") {
|
||||
storeFile = System.getenv("ANDROID_RELEASE_KEYSTORE")
|
||||
?.takeIf { it.isNotBlank() }?.let { rootProject.file(it) }
|
||||
storePassword = System.getenv("ANDROID_RELEASE_KEYSTORE_PASSWORD")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
keyAlias = "rhema-release"
|
||||
keyPassword = storePassword
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
// Signed only where the release credentials exist; the
|
||||
// unsigned artifact is a build check, never a deliverable.
|
||||
if (!System.getenv("ANDROID_RELEASE_KEYSTORE")
|
||||
.isNullOrBlank()
|
||||
) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2025.12.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation("androidx.activity:activity-compose:1.12.4")
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||
implementation("androidx.work:work-runtime-ktx:2.12.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
// 0.42+ declares minCompileSdk 37 in its AAR metadata; the project's
|
||||
// AGP tops out at 36. Revisit when AGP moves.
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer:0.41.0")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:0.41.0")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Network, posting the agent's notifications, and re-kicking the
|
||||
workers after a reboot. Location is the status source's optional
|
||||
extra: a runtime permission, and without it the location topic
|
||||
simply never reports. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!-- DND control is a special-app grant, and this declaration is what
|
||||
puts the app on the "Do Not Disturb access" screen's list: an
|
||||
undeclared app opens that screen and finds nothing to flip. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
<!-- App usage is not a runtime permission but a special-app access
|
||||
(Settings → Apps → Special app access → Usage access); declaring
|
||||
it is what makes the toggle able to grant it to this app. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<application
|
||||
android:name=".RhemaApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/ic_rhema"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- WorkManager already re-enqueues its own periodic work after reboot;
|
||||
this receiver is the explicit boot kick. It only calls the same
|
||||
idempotent (KEEP) schedule call used at app start. -->
|
||||
<receiver
|
||||
android:name=".work.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Attachments saved into the cache open in other apps only through
|
||||
this provider's content:// mapping; a raw cache path is
|
||||
unreadable to them. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,126 @@
|
||||
package rs.rhema.phone
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.ui.ChatScreen
|
||||
import rs.rhema.phone.ui.JadeiteGround
|
||||
import rs.rhema.phone.ui.RhemaTheme
|
||||
import rs.rhema.phone.ui.SessionsScreen
|
||||
import rs.rhema.phone.ui.SettingsScreen
|
||||
import rs.rhema.phone.work.enqueuePhoneCommandsNow
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
private val locationPermissions =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Edge-to-edge with the design's dark ground behind the system
|
||||
// bars; the theme draws under them, the root pads them out.
|
||||
enableEdgeToEdge(SystemBarStyle.dark(JadeiteGround.toArgb()))
|
||||
super.onCreate(savedInstanceState)
|
||||
// POST_NOTIFICATIONS is a runtime permission on API 33+. Asking every
|
||||
// cold start is deliberate; the OS throttles repeated denials itself.
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
// Location is the status source's optional sensor: the request rides
|
||||
// every cold start like the notifications one, and a refusal costs
|
||||
// only the location topic — everything else reports as before. The
|
||||
// Settings screen's Status reporting card names the missing grants
|
||||
// and walks to the system toggles.
|
||||
locationPermissions.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
),
|
||||
)
|
||||
val container = (application as RhemaApp).container
|
||||
setContent {
|
||||
RhemaTheme {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
AppRoot(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The control queue (phone commands) is freshest when the human is
|
||||
// holding the phone; KEEP coalesces rapid resume bursts.
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
enqueuePhoneCommandsNow(applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(container: AppContainer) {
|
||||
// null means "still loading stored settings", not "unconfigured".
|
||||
val configured by container.settings.configured.collectAsState(initial = null)
|
||||
var chatSession by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = chatSession != null || showSettings) {
|
||||
chatSession = null
|
||||
showSettings = false
|
||||
}
|
||||
|
||||
// The agent's identity (name, avatar) is refreshed once per app open
|
||||
// and cached in DataStore; the sessions screen's refresh re-fetches it.
|
||||
LaunchedEffect(configured) {
|
||||
if (configured == true) container.identity.refresh()
|
||||
}
|
||||
|
||||
when {
|
||||
configured == null -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
// Unconfigured: first launch. SettingsScreen calls onConnected (a no-op
|
||||
// here); flipping `configured` in DataStore recomposes this root.
|
||||
configured == false -> SettingsScreen(container)
|
||||
showSettings -> SettingsScreen(container) { showSettings = false }
|
||||
chatSession != null -> {
|
||||
val key = chatSession
|
||||
if (key != null) ChatScreen(container, key) { chatSession = null }
|
||||
}
|
||||
else -> SessionsScreen(
|
||||
container,
|
||||
onOpenSession = { chatSession = it },
|
||||
onOpenSettings = { showSettings = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package rs.rhema.phone
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.content.ContextCompat
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.work.PowerEventReceiver
|
||||
|
||||
class RhemaApp : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
// Held in a field so the registered receiver cannot be collected while
|
||||
// the process lives.
|
||||
private var powerReceiver: PowerEventReceiver? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
container.notifier.ensureChannel()
|
||||
container.schedulePeriodicWork()
|
||||
|
||||
// ACTION_BATTERY_CHANGED and the power actions are implicit broadcasts:
|
||||
// manifest registration for them is refused, so this receiver lives for
|
||||
// the process lifetime instead. It stays dumb: it only enqueues work.
|
||||
val receiver = PowerEventReceiver()
|
||||
powerReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
receiver,
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_POWER_CONNECTED)
|
||||
addAction(Intent.ACTION_POWER_DISCONNECTED)
|
||||
addAction(Intent.ACTION_BATTERY_CHANGED)
|
||||
},
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package rs.rhema.phone.chat
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import rs.rhema.phone.data.CachedSession
|
||||
import rs.rhema.phone.data.SESSION_WINDOW
|
||||
import rs.rhema.phone.data.SessionCache
|
||||
import rs.rhema.phone.net.ClawApi
|
||||
import rs.rhema.phone.net.ClawEvent
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import rs.rhema.phone.net.SseFrame
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
|
||||
// Holds the live transcript of one open chat screen. The transcript is
|
||||
// seeded from the session cache before the first frame and kept in the
|
||||
// cache as events stream, so opening a session paints it at once and the
|
||||
// claw is asked only for the events past the cache (the stream's `since`
|
||||
// cursor). Rotation re-seeds from the cache; process death no longer
|
||||
// costs the transcript.
|
||||
class ChatController(
|
||||
private val api: ClawApi,
|
||||
private val cache: SessionCache,
|
||||
private val scope: CoroutineScope,
|
||||
val sessionKey: String,
|
||||
) {
|
||||
sealed interface Item {
|
||||
val id: Long
|
||||
}
|
||||
|
||||
data class UserMessage(override val id: Long, val text: String) : Item
|
||||
data class Thinking(override val id: Long, val text: String) : Item
|
||||
data class ToolCall(
|
||||
override val id: Long,
|
||||
val name: String,
|
||||
val summary: String?,
|
||||
val isError: Boolean,
|
||||
val running: Boolean,
|
||||
// Command tools: the shell line and the exit code, both null
|
||||
// until the tool_result lands (and null forever on the others).
|
||||
val command: String? = null,
|
||||
val exitCode: Long? = null,
|
||||
) : Item
|
||||
data class Reply(override val id: Long, val text: String, val streaming: Boolean) : Item
|
||||
|
||||
// A file the agent produced; the bytes stay at the claw until this
|
||||
// screen fetches them by attachmentId.
|
||||
data class Attachment(
|
||||
override val id: Long,
|
||||
val attachmentId: String,
|
||||
val name: String,
|
||||
val media: String,
|
||||
val size: Long,
|
||||
val caption: String?,
|
||||
) : Item
|
||||
|
||||
// The agent's question to the human, answered in place. granted is
|
||||
// null while open; consent_resolved (or a local answer) flips it.
|
||||
// Replay rebuilds the whole ladder, so nothing persists here.
|
||||
data class Consent(
|
||||
override val id: Long,
|
||||
val consentId: String,
|
||||
val kind: String,
|
||||
val summary: String,
|
||||
val detail: String,
|
||||
val granted: Boolean? = null,
|
||||
) : Item
|
||||
|
||||
// The turn's end tag ("Handled", "Failed", …) from turn_done; the
|
||||
// reply carries the text, this carries the outcome.
|
||||
data class Outcome(override val id: Long, val outcome: String) : Item
|
||||
|
||||
sealed interface Conn {
|
||||
data object Connecting : Conn
|
||||
data object Live : Conn
|
||||
data object Reconnecting : Conn
|
||||
data class Failed(val code: Int?, val message: String) : Conn
|
||||
}
|
||||
|
||||
var items by mutableStateOf<List<Item>>(emptyList())
|
||||
private set
|
||||
var connection by mutableStateOf<Conn>(Conn.Connecting)
|
||||
private set
|
||||
var sending by mutableStateOf(false)
|
||||
private set
|
||||
var sendError by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private var nextId = 0L
|
||||
private fun newId() = nextId++
|
||||
|
||||
// The applied event log: the cache's tail plus what this connection
|
||||
// delivered, and [appliedTotal] its count from the session's first
|
||||
// event — the cursor every (re)connection opens with.
|
||||
private var applied: MutableList<ClawEvent> = mutableListOf()
|
||||
private var appliedTotal = 0L
|
||||
|
||||
private var streamJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (streamJob != null) return
|
||||
streamJob = scope.launch {
|
||||
// Seed before the first frame: the screen shows the cached
|
||||
// transcript at once, and the stream opens past it.
|
||||
val cached = cache.get(sessionKey)
|
||||
appliedTotal = cached.total
|
||||
applied = cached.events.toMutableList()
|
||||
items = applyEvents(emptyList(), applied)
|
||||
try {
|
||||
while (isActive) {
|
||||
api.eventStream(sessionKey, appliedTotal).collect { frame ->
|
||||
when (frame) {
|
||||
// The replay was cut at the cursor, so nothing
|
||||
// already on screen arrives twice; the feed just
|
||||
// continues from where the cache left off.
|
||||
SseFrame.Opened -> connection = Conn.Live
|
||||
is SseFrame.Event -> record(frame.event)
|
||||
SseFrame.Closed -> Unit
|
||||
is SseFrame.Failed -> connection = Conn.Failed(frame.code, frame.message)
|
||||
}
|
||||
}
|
||||
// A wrong token does not fix itself by retrying.
|
||||
val conn = connection
|
||||
if (conn is Conn.Failed && conn.code == 401) break
|
||||
connection = Conn.Reconnecting
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
}
|
||||
} finally {
|
||||
// The screen's own cancellation lands here too: flush the
|
||||
// cache so the next open seeds from the true tail.
|
||||
withContext(NonCancellable) { persist() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Appends one delivered event to the log and the transcript. The
|
||||
// turn's end persists the cache — once per turn, not per delta.
|
||||
private suspend fun record(event: ClawEvent) {
|
||||
applied.add(event)
|
||||
appliedTotal += 1
|
||||
items = applyEvent(items, event)
|
||||
if (event is ClawEvent.TurnDone) persist()
|
||||
}
|
||||
|
||||
private suspend fun persist() {
|
||||
val window = applied.takeLast(SESSION_WINDOW)
|
||||
cache.put(sessionKey, CachedSession(total = appliedTotal, events = window))
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
streamJob?.cancel()
|
||||
streamJob = null
|
||||
}
|
||||
|
||||
// The stream is already collecting by the time the user can press send,
|
||||
// so this preserves the claw's subscribe-then-post order. A failed POST
|
||||
// restores the draft in the UI. The turn's user_message event is the
|
||||
// transcript's own record of the question — replayed on a reload, and
|
||||
// echoed live right about when this returns — so the local row is only
|
||||
// appended when the echo has not already landed.
|
||||
suspend fun send(text: String): Boolean {
|
||||
sending = true
|
||||
sendError = null
|
||||
val result = api.postMessage(sessionKey, text)
|
||||
sending = false
|
||||
return when (result) {
|
||||
is ClawResult.Ok -> {
|
||||
val last = items.lastOrNull()
|
||||
if (!(last is UserMessage && last.text == text)) {
|
||||
items = items + UserMessage(newId(), text)
|
||||
}
|
||||
true
|
||||
}
|
||||
is ClawResult.HttpError -> {
|
||||
sendError = "HTTP ${result.code}" +
|
||||
(result.body?.take(200)?.let { " — $it" } ?: "")
|
||||
false
|
||||
}
|
||||
is ClawResult.Network -> {
|
||||
sendError = result.message
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folds recorded events into a transcript, the cache seed's path:
|
||||
// the same reducer the live stream applies, run from the cache's tail.
|
||||
private fun applyEvents(items: List<Item>, events: List<ClawEvent>): List<Item> =
|
||||
events.fold(items) { acc, event -> applyEvent(acc, event) }
|
||||
|
||||
private fun applyEvent(items: List<Item>, event: ClawEvent): List<Item> {
|
||||
var list = items
|
||||
when (event) {
|
||||
is ClawEvent.UserMessage -> {
|
||||
// The recorded question — replayed on reload, echoed live
|
||||
// for a message this surface just sent. An identical last
|
||||
// row is that echo's stand-in; skip it.
|
||||
val last = list.lastOrNull()
|
||||
if (!(last is UserMessage && last.text == event.text)) {
|
||||
list = list + UserMessage(newId(), event.text)
|
||||
}
|
||||
}
|
||||
is ClawEvent.ThinkingDelta -> {
|
||||
val last = list.lastOrNull()
|
||||
list = if (last is Thinking) {
|
||||
list.dropLast(1) + last.copy(text = last.text + event.text)
|
||||
} else {
|
||||
list + Thinking(newId(), event.text)
|
||||
}
|
||||
}
|
||||
is ClawEvent.TextDelta -> {
|
||||
// The turn has ONE reply: deltas append to the last open
|
||||
// reply even after tool rows landed between them, so the
|
||||
// agent's text reads continuously around its tool cards.
|
||||
val index = list.indexOfLast { it is Reply && it.streaming }
|
||||
list = if (index >= 0) {
|
||||
list.toMutableList().also { mutable ->
|
||||
val reply = mutable[index] as Reply
|
||||
mutable[index] = reply.copy(text = reply.text + event.text)
|
||||
}
|
||||
} else {
|
||||
list + Reply(newId(), event.text, streaming = true)
|
||||
}
|
||||
}
|
||||
is ClawEvent.ToolStart ->
|
||||
list = list + ToolCall(newId(), event.name, summary = null, isError = false, running = true)
|
||||
is ClawEvent.ToolResult -> {
|
||||
// tool_result has no index; match the last open row of the same tool.
|
||||
val index = list.indexOfLast { it is ToolCall && it.name == event.name && it.running }
|
||||
list = if (index >= 0) {
|
||||
list.toMutableList().also { mutable ->
|
||||
val tool = mutable[index] as ToolCall
|
||||
mutable[index] = tool.copy(
|
||||
summary = event.summary,
|
||||
isError = event.isError,
|
||||
running = false,
|
||||
command = event.command,
|
||||
exitCode = event.code,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
list + ToolCall(
|
||||
newId(),
|
||||
event.name,
|
||||
event.summary,
|
||||
event.isError,
|
||||
running = false,
|
||||
command = event.command,
|
||||
exitCode = event.code,
|
||||
)
|
||||
}
|
||||
}
|
||||
is ClawEvent.TurnDone -> {
|
||||
// Thinking is scratch: it disappears once the turn settles, and
|
||||
// the pinned reply replaces whatever streamed in. Every reply
|
||||
// stops streaming; the last one carries the final text.
|
||||
val kept = list.filterNot { it is Thinking }
|
||||
val replyIndex = kept.indexOfLast { it is Reply }
|
||||
list = kept.mapIndexed { i, item ->
|
||||
if (item !is Reply) {
|
||||
item
|
||||
} else if (i == replyIndex && !event.reply.isNullOrEmpty()) {
|
||||
item.copy(text = event.reply, streaming = false)
|
||||
} else {
|
||||
item.copy(streaming = false)
|
||||
}
|
||||
}
|
||||
event.outcome?.let { outcome -> list = list + Outcome(newId(), outcome) }
|
||||
}
|
||||
is ClawEvent.Attachment ->
|
||||
list = list + Attachment(newId(), event.id, event.name, event.media, event.size, event.caption)
|
||||
is ClawEvent.ConsentRequest ->
|
||||
list = list + Consent(newId(), event.id, event.kind, event.summary, event.detail)
|
||||
is ClawEvent.ConsentResolved -> list = flipConsent(list, event.id, event.granted)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Answers a consent question. The claw's consent_resolved event is the
|
||||
// transcript's authority and flips the card again (idempotent); the
|
||||
// local flip covers the gap before the event lands. The raw result
|
||||
// rides back so the card can show what failed.
|
||||
suspend fun answerSecret(consentId: String, secret: String): ClawResult<Unit> =
|
||||
settle(consentId, granted = true) { api.revealSecret(consentId, secret) }
|
||||
|
||||
suspend fun grantPhone(consentId: String, grant: Boolean): ClawResult<Unit> =
|
||||
settle(consentId, granted = grant) { api.grantPhone(consentId, grant) }
|
||||
|
||||
suspend fun denyConsent(consentId: String): ClawResult<Unit> =
|
||||
settle(consentId, granted = false) { api.denyConsent(consentId) }
|
||||
|
||||
private suspend fun settle(
|
||||
consentId: String,
|
||||
granted: Boolean,
|
||||
call: suspend () -> ClawResult<Unit>,
|
||||
): ClawResult<Unit> {
|
||||
val result = call()
|
||||
if (result is ClawResult.Ok) items = flipConsent(items, consentId, granted)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun flipConsent(items: List<Item>, consentId: String, granted: Boolean): List<Item> {
|
||||
val index = items.indexOfLast { it is Consent && it.consentId == consentId }
|
||||
if (index < 0) return items
|
||||
return items.toMutableList().also { list ->
|
||||
val consent = list[index] as Consent
|
||||
list[index] = consent.copy(granted = granted)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RECONNECT_DELAY_MS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package rs.rhema.phone.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.byteArrayPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import rs.rhema.phone.net.AgentIdentity
|
||||
import rs.rhema.phone.net.ClawApi
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
|
||||
// The agent's face and name as the phone last saw them: the identity
|
||||
// document plus the avatar bytes. Cached in DataStore so the last known
|
||||
// identity renders before (and without) a network round trip — the chat
|
||||
// screen must never fall back to a hardcoded "rhema" just because one
|
||||
// refresh failed.
|
||||
class AgentIdentityCache(
|
||||
val identity: AgentIdentity? = null,
|
||||
val avatar: ByteArray? = null,
|
||||
)
|
||||
|
||||
class AgentIdentityRepository(
|
||||
private val store: DataStore<Preferences>,
|
||||
private val api: ClawApi,
|
||||
private val json: Json,
|
||||
) {
|
||||
private object Keys {
|
||||
val identityJson = stringPreferencesKey("agent_identity_json")
|
||||
val avatarBytes = byteArrayPreferencesKey("agent_avatar_bytes")
|
||||
}
|
||||
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
val cached: Flow<AgentIdentityCache> = store.data.map { prefs ->
|
||||
val identity = prefs[Keys.identityJson]?.let { raw ->
|
||||
runCatching { json.decodeFromString<AgentIdentity>(raw) }.getOrNull()
|
||||
}
|
||||
AgentIdentityCache(identity, prefs[Keys.avatarBytes])
|
||||
}
|
||||
|
||||
// Fetches the identity and, when one is set, the avatar bytes. Every
|
||||
// failure keeps the cache as it was: this is presentation state, not
|
||||
// a result the UI owes the user an error for.
|
||||
suspend fun refresh() = refreshMutex.withLock {
|
||||
val identity = when (val result = api.agent()) {
|
||||
is ClawResult.Ok -> result.value
|
||||
else -> return@withLock
|
||||
}
|
||||
val avatar = if (identity.avatar) {
|
||||
when (val result = api.agentAvatar()) {
|
||||
is ClawResult.Ok -> result.value.takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val encoded = json.encodeToString(identity)
|
||||
store.edit { prefs ->
|
||||
if (prefs[Keys.identityJson] != encoded) prefs[Keys.identityJson] = encoded
|
||||
val currentBytes = prefs[Keys.avatarBytes]
|
||||
when {
|
||||
// Rewrite only on change: the avatar can weigh megabytes and
|
||||
// every refresh would otherwise rewrite it into the store.
|
||||
identity.avatar && avatar != null ->
|
||||
if (currentBytes?.contentEquals(avatar) != true) {
|
||||
prefs[Keys.avatarBytes] = avatar
|
||||
}
|
||||
identity.avatar == false && currentBytes != null ->
|
||||
prefs.remove(Keys.avatarBytes)
|
||||
// The identity says an avatar exists but the fetch failed:
|
||||
// the cached bytes stay until a fetch succeeds.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package rs.rhema.phone.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
// One DataStore file holds everything the app persists; the two repositories
|
||||
// in this package are views over it.
|
||||
val Context.rhemaStore: DataStore<Preferences> by preferencesDataStore(name = "rhema")
|
||||
@@ -0,0 +1,45 @@
|
||||
package rs.rhema.phone.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import rs.rhema.phone.net.ClawEvent
|
||||
|
||||
// One session's cached transcript tail: the events this device has
|
||||
// applied, persisted as they stream. Opening a session seeds the screen
|
||||
// from here instead of watching the claw's whole replay again, and the
|
||||
// stream opens with `total` as its `since` cursor so the claw sends only
|
||||
// the events past the cache.
|
||||
@Serializable
|
||||
data class CachedSession(
|
||||
// The session's full event count — the `since` cursor. Can exceed
|
||||
// [events]: the list keeps only the newest [SESSION_WINDOW] events,
|
||||
// the count counts from the session's first.
|
||||
val total: Long = 0,
|
||||
val events: List<ClawEvent> = emptyList(),
|
||||
)
|
||||
|
||||
// How many events the cache keeps per session. A resident session runs
|
||||
// for weeks; a bounded window keeps the DataStore write flat, and a tail
|
||||
// is enough to paint the screen — the claw still holds the whole record.
|
||||
const val SESSION_WINDOW = 500
|
||||
|
||||
class SessionCache(private val store: DataStore<Preferences>, private val json: Json) {
|
||||
|
||||
private fun key(sessionKey: String) = stringPreferencesKey("session_cache:$sessionKey")
|
||||
|
||||
/** The cached tail, or an empty one for a session never opened here. */
|
||||
suspend fun get(sessionKey: String): CachedSession =
|
||||
store.data.first()[key(sessionKey)]?.let { raw ->
|
||||
runCatching { json.decodeFromString<CachedSession>(raw) }.getOrNull()
|
||||
} ?: CachedSession()
|
||||
|
||||
suspend fun put(sessionKey: String, cache: CachedSession) {
|
||||
store.edit { it[key(sessionKey)] = json.encodeToString(cache) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package rs.rhema.phone.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
// The bearer token lives in plain app-private DataStore, not
|
||||
// EncryptedSharedPreferences: androidx.security:security-crypto is an
|
||||
// alpha-quality, effectively unmaintained artifact, and the keystore ceremony
|
||||
// it wraps buys little against the actual threat (another app) since the OS
|
||||
// sandbox already isolates the file. Trade-off: root or a backup extraction
|
||||
// can read it; backup is disabled in the manifest.
|
||||
class SettingsRepository(private val store: DataStore<Preferences>) {
|
||||
|
||||
data class Credentials(val baseUrl: String, val token: String) {
|
||||
val configured: Boolean get() = baseUrl.isNotBlank()
|
||||
}
|
||||
|
||||
private object Keys {
|
||||
val baseUrl = stringPreferencesKey("claw_base_url")
|
||||
val token = stringPreferencesKey("claw_token")
|
||||
val deviceId = stringPreferencesKey("device_id")
|
||||
}
|
||||
|
||||
val credentials: Flow<Credentials> =
|
||||
store.data.map { Credentials(it[Keys.baseUrl].orEmpty(), it[Keys.token].orEmpty()) }
|
||||
|
||||
// True once a base URL is stored. The token is needed for /v1 but not for
|
||||
// /healthz, so only the URL gates the first-launch settings screen.
|
||||
val configured: Flow<Boolean> = credentials.map { it.configured }
|
||||
|
||||
// UI-facing id stream; workers use the suspend deviceId() below.
|
||||
val deviceIdFlow: Flow<String> = store.data.map { it[Keys.deviceId].orEmpty() }
|
||||
|
||||
suspend fun current(): Credentials = credentials.first()
|
||||
|
||||
suspend fun setCredentials(rawBaseUrl: String, token: String) {
|
||||
// Normalize the base: trim, drop trailing slashes, and drop a
|
||||
// trailing `/v1` — the client appends the API paths itself, and a
|
||||
// pasted `…/v1` would double-prefix every call into the UI's
|
||||
// catch-all. Also downgrade the host spelling of a schemeless
|
||||
// paste by prepending http, the self-hosted default.
|
||||
val trimmed = rawBaseUrl.trim().trimEnd('/')
|
||||
val base = if (trimmed.endsWith("/v1")) trimmed.removeSuffix("/v1") else trimmed
|
||||
val baseUrl = when {
|
||||
base.startsWith("http://") || base.startsWith("https://") -> base
|
||||
base.isNotEmpty() -> "http://$base"
|
||||
else -> base
|
||||
}
|
||||
store.edit {
|
||||
it[Keys.baseUrl] = baseUrl
|
||||
it[Keys.token] = token
|
||||
}
|
||||
}
|
||||
|
||||
// Generated once, then immutable for the life of the install; ingest keys
|
||||
// and the notification cursor are namespaced by it.
|
||||
suspend fun deviceId(): String {
|
||||
store.data.first()[Keys.deviceId]?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
store.edit { prefs ->
|
||||
if (prefs[Keys.deviceId].isNullOrBlank()) {
|
||||
prefs[Keys.deviceId] = UUID.randomUUID().toString()
|
||||
}
|
||||
}
|
||||
val id = store.data.first()[Keys.deviceId]
|
||||
check(!id.isNullOrBlank())
|
||||
return id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package rs.rhema.phone.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// What the last successful phone-status run reported. A null field means the
|
||||
// sensor was unreadable or ungranted on this device; null fields never produce
|
||||
// ingest posts. New fields default to null, so a snapshot persisted by an
|
||||
// older build decodes (and re-posts each new topic once, which is what a
|
||||
// first report should do).
|
||||
@Serializable
|
||||
data class PhoneSnapshot(
|
||||
val charging: Boolean? = null,
|
||||
val batteryLevel: Int? = null,
|
||||
val network: String? = null,
|
||||
val dnd: String? = null,
|
||||
// The last posted location, kept for the move-distance rule.
|
||||
val locationLat: Double? = null,
|
||||
val locationLon: Double? = null,
|
||||
// The last posted foreground app package.
|
||||
val app: String? = null,
|
||||
// The last posted device-identity fingerprint.
|
||||
val device: String? = null,
|
||||
val wifi: String? = null,
|
||||
// The next alarm's trigger time, epoch millis.
|
||||
val alarm: Long? = null,
|
||||
)
|
||||
|
||||
class StatusRepository(private val store: DataStore<Preferences>, private val json: Json) {
|
||||
|
||||
private object Keys {
|
||||
val snapshot = stringPreferencesKey("last_phone_snapshot")
|
||||
val notificationCursor = stringPreferencesKey("last_notification_id")
|
||||
}
|
||||
|
||||
suspend fun lastSnapshot(): PhoneSnapshot? =
|
||||
store.data.first()[Keys.snapshot]?.let { raw ->
|
||||
runCatching { json.decodeFromString<PhoneSnapshot>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
// Only called after every ingest post of a run succeeded, so a failed run
|
||||
// retries the whole transition set next time instead of swallowing it.
|
||||
suspend fun setSnapshot(snapshot: PhoneSnapshot) {
|
||||
store.edit { it[Keys.snapshot] = json.encodeToString(snapshot) }
|
||||
}
|
||||
|
||||
// Cursor into the claw's notification list: the last id this device showed.
|
||||
val notificationCursor: Flow<String> =
|
||||
store.data.map { it[Keys.notificationCursor].orEmpty() }
|
||||
|
||||
suspend fun setNotificationCursor(id: String) {
|
||||
store.edit { it[Keys.notificationCursor] = id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package rs.rhema.phone.di
|
||||
|
||||
import android.content.Context
|
||||
import rs.rhema.phone.data.AgentIdentityRepository
|
||||
import rs.rhema.phone.data.SessionCache
|
||||
import rs.rhema.phone.data.SettingsRepository
|
||||
import rs.rhema.phone.data.StatusRepository
|
||||
import rs.rhema.phone.data.rhemaStore
|
||||
import rs.rhema.phone.net.ClawApi
|
||||
import rs.rhema.phone.notif.Notifier
|
||||
import rs.rhema.phone.work.schedulePeriodicWork
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
// Manual DI: one object graph, built once in Application.onCreate, passed
|
||||
// down by hand. No framework on purpose.
|
||||
class AppContainer(context: Context) {
|
||||
private val appContext: Context = context.applicationContext
|
||||
|
||||
val json: Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
val settings = SettingsRepository(appContext.rhemaStore)
|
||||
val status = StatusRepository(appContext.rhemaStore, json)
|
||||
val sessionCache = SessionCache(appContext.rhemaStore, json)
|
||||
|
||||
// readTimeout is zeroed only for the SSE client; regular calls keep a
|
||||
// timeout so a wedged claw cannot hang a POST forever.
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val api = ClawApi(settings, http, json)
|
||||
|
||||
val identity = AgentIdentityRepository(appContext.rhemaStore, api, json)
|
||||
|
||||
val notifier = Notifier(appContext)
|
||||
|
||||
fun schedulePeriodicWork() = schedulePeriodicWork(appContext)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package rs.rhema.phone.net
|
||||
|
||||
import rs.rhema.phone.data.SettingsRepository
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
|
||||
sealed interface ClawResult<out T> {
|
||||
data class Ok<T>(val value: T) : ClawResult<T>
|
||||
data class HttpError(val code: Int, val body: String?) : ClawResult<Nothing>
|
||||
data class Network(val message: String) : ClawResult<Nothing>
|
||||
}
|
||||
|
||||
sealed interface SseFrame {
|
||||
data object Opened : SseFrame
|
||||
data class Event(val event: ClawEvent) : SseFrame
|
||||
|
||||
// code != null carries an HTTP status; 401 means the token is wrong.
|
||||
data class Failed(val code: Int?, val message: String) : SseFrame
|
||||
data object Closed : SseFrame
|
||||
}
|
||||
|
||||
// One object per claw endpoint. Paths mirror the claw's /v1 contract exactly;
|
||||
// the bearer token rides on every /v1 call and never on /healthz.
|
||||
class ClawApi(
|
||||
private val settings: SettingsRepository,
|
||||
baseClient: OkHttpClient,
|
||||
private val json: Json,
|
||||
) {
|
||||
private val client = baseClient
|
||||
|
||||
// The event stream must not time out while idle; keep-alive comments are
|
||||
// the only liveness signal. Regular calls keep the 30s default from baseClient.
|
||||
private val streamingClient = baseClient.newBuilder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonBody = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// /healthz is the only unauthenticated endpoint; it is the settings gate.
|
||||
suspend fun health(): ClawResult<Unit> {
|
||||
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(Request.Builder().url("$base/healthz").build()) { }
|
||||
}
|
||||
|
||||
suspend fun sessions(): ClawResult<List<SessionInfo>> {
|
||||
val builder = v1("sessions") ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.get().build()) { response ->
|
||||
json.decodeFromString<List<SessionInfo>>(response.body?.string() ?: "[]")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /v1/providers: every backend instance and the active pair.
|
||||
suspend fun providers(): ClawResult<ProvidersState> {
|
||||
val builder = v1("providers") ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.get().build()) { response ->
|
||||
json.decodeFromString<ProvidersState>(response.body?.string() ?: "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /v1/providers/{id}/models: the instance's live listing. An id
|
||||
// without one (unknown, or the mock) is a 404 — an ordinary result,
|
||||
// not a failure: the instance still switches on its default model.
|
||||
suspend fun providerModels(id: String): ClawResult<ModelListing> {
|
||||
val builder = v1("providers", id, "models")
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.get().build()) { response ->
|
||||
json.decodeFromString<ModelListing>(response.body?.string() ?: "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /v1/providers/active: switch the backend instance (by id),
|
||||
// persisted claw-side;
|
||||
// it applies from the next turn. The answer carries the new pair only.
|
||||
suspend fun setActiveProvider(provider: String, model: String?): ClawResult<ActiveBackend> {
|
||||
val builder = v1("providers", "active")
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
val body = json.encodeToString(ActiveRequest(provider, model)).toRequestBody(jsonBody)
|
||||
return execute(builder.put(body).build()) { response ->
|
||||
json.decodeFromString<ActiveBackend>(response.body?.string() ?: "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// The claw's documented dance: subscribe to the event stream first, then
|
||||
// POST; the reply arrives on the stream, not in the POST response (202).
|
||||
suspend fun postMessage(sessionKey: String, text: String): ClawResult<Unit> {
|
||||
val builder = v1("sessions", sessionKey, "messages")
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
val body = json.encodeToString(MessageRequest(text)).toRequestBody(jsonBody)
|
||||
return execute(builder.post(body).build()) { }
|
||||
}
|
||||
|
||||
suspend fun ingest(source: String, key: String, payload: JsonObject): ClawResult<Unit> {
|
||||
val builder = v1("ingest") ?: return ClawResult.Network("no base URL configured")
|
||||
val body = json.encodeToString(IngestRequest(source, key, payload)).toRequestBody(jsonBody)
|
||||
return execute(builder.post(body).build()) { }
|
||||
}
|
||||
|
||||
// GET /v1/phones: the device fleet, for the settings screen's
|
||||
// management card.
|
||||
suspend fun phones(): ClawResult<PhonesPage> {
|
||||
val builder = v1("phones") ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.get().build()) { response ->
|
||||
json.decodeFromString<PhonesPage>(response.body?.string() ?: "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /v1/phones/{device}: retire a device's records on the claw.
|
||||
// The claw answers {"forgotten": bool} — false for an unknown name,
|
||||
// which only means another surface got there first.
|
||||
suspend fun forgetPhone(device: String): ClawResult<Unit> {
|
||||
val builder = v1("phones", device) ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.delete().build()) { }
|
||||
}
|
||||
|
||||
suspend fun notifications(deviceId: String, since: String): ClawResult<List<RhemaNotification>> {
|
||||
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
|
||||
val url = base.toHttpUrlOrNull()?.newBuilder()
|
||||
?.addPathSegments("v1/notifications")
|
||||
?.addQueryParameter("device", deviceId)
|
||||
?.addQueryParameter("since", since)
|
||||
?.build()
|
||||
?: return ClawResult.Network("bad base URL")
|
||||
val request = Request.Builder().url(url).header("Authorization", bearer()).build()
|
||||
return execute(request) { response ->
|
||||
json.decodeFromString<NotificationPage>(response.body?.string() ?: "{}").notifications
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ackNotification(id: String): ClawResult<Unit> {
|
||||
val builder = v1("notifications", id, "ack")
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.post(EMPTY_BODY).build()) { }
|
||||
}
|
||||
|
||||
// GET /v1/attachments/{id} and GET /v1/agent/avatar: raw bytes, not
|
||||
// JSON. The generic execute() rejects any 2xx body that does not open
|
||||
// like JSON, so these endpoints share one plain read with the same
|
||||
// error vocabulary.
|
||||
suspend fun attachment(id: String): ClawResult<ByteArray> {
|
||||
val builder = v1("attachments", id)
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
return rawGet(builder)
|
||||
}
|
||||
|
||||
// The avatar behind GET /v1/agent; the identity document's `avatar`
|
||||
// flag says whether the fetch can succeed.
|
||||
suspend fun agentAvatar(): ClawResult<ByteArray> {
|
||||
val builder = v1("agent", "avatar")
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
return rawGet(builder)
|
||||
}
|
||||
|
||||
private suspend fun rawGet(builder: Request.Builder): ClawResult<ByteArray> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
client.newCall(builder.get().build()).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
ClawResult.Ok(response.body?.bytes() ?: ByteArray(0))
|
||||
} else {
|
||||
ClawResult.HttpError(response.code, response.body?.string()?.take(500))
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
ClawResult.Network(e.message ?: "network error")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /v1/agent: the agent's presentation identity — the name, mark,
|
||||
// initials, and whether the avatar image is set.
|
||||
suspend fun agent(): ClawResult<AgentIdentity> {
|
||||
val builder = v1("agent") ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.get().build()) { response ->
|
||||
json.decodeFromString<AgentIdentity>(response.body?.string() ?: "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /v1/consents: the pending questions, oldest first.
|
||||
suspend fun consents(): ClawResult<List<ConsentInfo>> {
|
||||
val builder = v1("consents") ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.get().build()) { response ->
|
||||
json.decodeFromString<ConsentsPage>(response.body?.string() ?: "{}").consents
|
||||
}
|
||||
}
|
||||
|
||||
// POST /v1/consents/{id}: the kind decides the body — the secret
|
||||
// string for kind=secret, the grant flag for kind=phone. A 409 means
|
||||
// the question was already answered (here or on another surface).
|
||||
suspend fun revealSecret(id: String, secret: String): ClawResult<Unit> {
|
||||
val builder = v1("consents", id) ?: return ClawResult.Network("no base URL configured")
|
||||
val body = json.encodeToString(SecretConsentAnswer(secret)).toRequestBody(jsonBody)
|
||||
return execute(builder.post(body).build()) { }
|
||||
}
|
||||
|
||||
suspend fun grantPhone(id: String, grant: Boolean): ClawResult<Unit> {
|
||||
val builder = v1("consents", id) ?: return ClawResult.Network("no base URL configured")
|
||||
val body = json.encodeToString(PhoneConsentAnswer(grant)).toRequestBody(jsonBody)
|
||||
return execute(builder.post(body).build()) { }
|
||||
}
|
||||
|
||||
// DELETE /v1/consents/{id}: deny, or cancel a question left open.
|
||||
suspend fun denyConsent(id: String): ClawResult<Unit> {
|
||||
val builder = v1("consents", id) ?: return ClawResult.Network("no base URL configured")
|
||||
return execute(builder.delete().build()) { }
|
||||
}
|
||||
|
||||
// GET /v1/phone/commands?device=: the control queue for this device.
|
||||
// Built by hand like notifications(): v1() bakes the URL into the
|
||||
// builder, leaving no room for query parameters.
|
||||
suspend fun phoneCommands(deviceId: String): ClawResult<List<PhoneCommand>> {
|
||||
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
|
||||
val url = base.toHttpUrlOrNull()?.newBuilder()
|
||||
?.addPathSegments("v1/phone/commands")
|
||||
?.addQueryParameter("device", deviceId)
|
||||
?.build()
|
||||
?: return ClawResult.Network("bad base URL")
|
||||
val request = Request.Builder().url(url).header("Authorization", bearer()).build()
|
||||
return execute(request) { response ->
|
||||
json.decodeFromString<CommandPage>(response.body?.string() ?: "{}").commands
|
||||
}
|
||||
}
|
||||
|
||||
// POST /v1/phone/commands/{id}/result: the ack. Until it lands the
|
||||
// claw keeps the command queued and the next run repeats it.
|
||||
suspend fun postPhoneCommandResult(id: String, ok: Boolean, detail: String?): ClawResult<Unit> {
|
||||
val builder = v1("phone", "commands", id, "result")
|
||||
?: return ClawResult.Network("no base URL configured")
|
||||
val body = json.encodeToString(CommandResult(ok, detail)).toRequestBody(jsonBody)
|
||||
return execute(builder.post(body).build()) { }
|
||||
}
|
||||
|
||||
// GET /v1/sessions/{key}/events?since=N: the claw replays the
|
||||
// session's record minus its oldest N events, then goes live. `since`
|
||||
// is the transcript cache's cursor — how many events this device has
|
||||
// already applied — so a reopening screen receives only what is new
|
||||
// instead of the whole history. Built by hand like notifications():
|
||||
// v1() bakes the URL into the builder, leaving no room for a query.
|
||||
fun eventStream(sessionKey: String, since: Long): Flow<SseFrame> = callbackFlow {
|
||||
val url = baseOrNull()?.toHttpUrlOrNull()?.newBuilder()
|
||||
?.addPathSegments("v1/sessions")
|
||||
?.addPathSegment(sessionKey)
|
||||
?.addPathSegment("events")
|
||||
?.apply { if (since > 0) addQueryParameter("since", since.toString()) }
|
||||
?.build()
|
||||
if (url == null) {
|
||||
trySend(SseFrame.Failed(null, "no base URL configured"))
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
val call = streamingClient.newCall(
|
||||
Request.Builder().url(url).header("Authorization", bearer()).build(),
|
||||
)
|
||||
// The chat screen cancels its collector on dispose; call.cancel() is
|
||||
// what unblocks the blocking read inside onResponse below.
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
trySend(SseFrame.Failed(null, e.message ?: "connection failed"))
|
||||
close()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use { resp ->
|
||||
if (resp.code == 401) {
|
||||
trySend(SseFrame.Failed(401, "unauthorized"))
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (!resp.isSuccessful) {
|
||||
trySend(SseFrame.Failed(resp.code, "HTTP ${resp.code}"))
|
||||
close()
|
||||
return
|
||||
}
|
||||
trySend(SseFrame.Opened)
|
||||
val source = resp.body?.source()
|
||||
if (source == null) {
|
||||
trySend(SseFrame.Failed(resp.code, "empty stream body"))
|
||||
close()
|
||||
return
|
||||
}
|
||||
// SSE framing per contract: blank-line-delimited data lines,
|
||||
// ":" comment lines are keep-alives. No event:/id: fields.
|
||||
val data = StringBuilder()
|
||||
try {
|
||||
while (true) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
when {
|
||||
line.startsWith(":") -> Unit
|
||||
line.isEmpty() -> {
|
||||
if (data.isNotEmpty()) {
|
||||
val payload = data.toString().trim()
|
||||
data.setLength(0)
|
||||
runCatching { json.decodeFromString<ClawEvent>(payload) }
|
||||
.getOrNull()
|
||||
?.let { trySend(SseFrame.Event(it)) }
|
||||
}
|
||||
}
|
||||
line.startsWith("data:") -> {
|
||||
data.append(line.removePrefix("data:").trimStart())
|
||||
data.append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
trySend(SseFrame.Closed)
|
||||
} catch (e: IOException) {
|
||||
// call.cancel() from awaitClose lands here: normal close.
|
||||
trySend(SseFrame.Closed)
|
||||
}
|
||||
close()
|
||||
}
|
||||
}
|
||||
})
|
||||
awaitClose { call.cancel() }
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
private suspend fun baseOrNull(): String? =
|
||||
settings.current().baseUrl.trim().trimEnd('/').takeIf { it.isNotBlank() }
|
||||
|
||||
// The claw's middleware strips the `Bearer ` prefix; the raw token
|
||||
// alone does not authorize.
|
||||
private suspend fun bearer(): String = "Bearer " + settings.current().token
|
||||
|
||||
// Builds GET-ready /v1/{...} request with auth; null when unconfigured.
|
||||
private suspend fun v1(vararg segments: String): Request.Builder? {
|
||||
// The `v1` prefix lives here and nowhere else: the callers name
|
||||
// only their own segments. Without it every call lands in the
|
||||
// web UI's catch-all and reads as a 2xx HTML page.
|
||||
val base = baseOrNull() ?: return null
|
||||
val url = base.toHttpUrlOrNull()?.newBuilder() ?: return null
|
||||
url.addPathSegment("v1")
|
||||
for (segment in segments) url.addPathSegment(segment)
|
||||
return Request.Builder().url(url.build()).header("Authorization", bearer())
|
||||
}
|
||||
|
||||
private suspend fun <T> execute(request: Request, parse: (Response) -> T): ClawResult<T> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
// Peek first: a 2xx body that is not the expected
|
||||
// JSON (an HTML page from a wrong route, a captive
|
||||
// portal) is an error result naming the body, never
|
||||
// a crash — and the snippet says who actually
|
||||
// answered.
|
||||
val peeked = response.peekBody(256).string().trimStart()
|
||||
if (!peeked.startsWith("{") && !peeked.startsWith("[")) {
|
||||
ClawResult.HttpError(
|
||||
response.code,
|
||||
"unexpected 2xx body: " + peeked.take(150),
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
ClawResult.Ok(parse(response))
|
||||
} catch (e: SerializationException) {
|
||||
// Valid JSON in the wrong shape fails here
|
||||
// too; the serializer's message names the
|
||||
// field, so it rides along with the body.
|
||||
ClawResult.HttpError(
|
||||
response.code,
|
||||
"unexpected response body: " +
|
||||
(e.message ?: "unparseable JSON") +
|
||||
" — body: " + peeked.take(150),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ClawResult.HttpError(response.code, response.body?.string()?.take(500))
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
ClawResult.Network(e.message ?: "network error")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val EMPTY_BODY = ByteArray(0).toRequestBody(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package rs.rhema.phone.net
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
// GET /v1/sessions
|
||||
@Serializable
|
||||
data class SessionInfo(
|
||||
val key: String,
|
||||
val state: String? = null,
|
||||
val revision: Long? = null,
|
||||
@SerialName("inbox_depth") val inboxDepth: Int? = null,
|
||||
// The claw serializes the outcome with serde's default externally
|
||||
// tagged enum form: unit variants as bare strings ("Handled",
|
||||
// "Superseded"), Failed as {"Failed":"reason"} — or null when the
|
||||
// key never released. Kept as raw JSON; the UI shows the variant
|
||||
// name.
|
||||
@SerialName("last_outcome") val lastOutcome: JsonElement? = null,
|
||||
)
|
||||
|
||||
// POST /v1/sessions/{key}/messages
|
||||
@Serializable
|
||||
data class MessageRequest(val text: String)
|
||||
|
||||
// GET /v1/providers — the backend instances and the active pair. Each
|
||||
// row is one instance, named by its id: the kind unless a login named
|
||||
// it, so two instances of one kind coexist under two ids.
|
||||
@Serializable
|
||||
data class ProviderInfo(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
@SerialName("default_model") val defaultModel: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ActivePair(val provider: String, val model: String)
|
||||
|
||||
@Serializable
|
||||
data class ProvidersState(val active: ActivePair, val providers: List<ProviderInfo>)
|
||||
|
||||
// GET /v1/providers/{id}/models — an instance's live listing; 404 when none.
|
||||
@Serializable
|
||||
data class ModelCard(
|
||||
val id: String,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelListing(val models: List<ModelCard> = emptyList())
|
||||
|
||||
// PUT /v1/providers/active — answers the new pair only (provider is the
|
||||
// instance id); the list is not echoed and the caller carries it forward.
|
||||
@Serializable
|
||||
data class ActiveRequest(val provider: String, val model: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class ActiveBackend(val active: ActivePair)
|
||||
|
||||
// POST /v1/ingest
|
||||
@Serializable
|
||||
data class IngestRequest(val source: String, val key: String, val payload: JsonObject)
|
||||
|
||||
// GET /v1/phones — the fleet: every device that ever reported, its
|
||||
// self-reported label, both clocks, and its latest observation per
|
||||
// topic. This device's row is the one whose `device` equals the local
|
||||
// device id.
|
||||
@Serializable
|
||||
data class PhonesPage(val phones: List<PhoneInfo> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class PhoneInfo(
|
||||
val device: String,
|
||||
val label: String? = null,
|
||||
@SerialName("first_seen") val firstSeen: Long = 0,
|
||||
@SerialName("last_seen") val lastSeen: Long = 0,
|
||||
// Topic name to its latest payload (battery, location, app, …),
|
||||
// verbatim from the ingest posts. Unknown topics land here too, so
|
||||
// an older build still lists a newer claw's data.
|
||||
val topics: Map<String, JsonObject> = emptyMap(),
|
||||
)
|
||||
|
||||
// GET /v1/notifications — the claw wraps the list in an object.
|
||||
@Serializable
|
||||
data class NotificationPage(val notifications: List<RhemaNotification> = emptyList())
|
||||
|
||||
// One queued notification: ids and timestamps are numeric (SQLite row id
|
||||
// and epoch millis), the ack path and the `since` cursor carry the id.
|
||||
@Serializable
|
||||
data class RhemaNotification(
|
||||
val id: Long,
|
||||
val device: String? = null,
|
||||
val title: String,
|
||||
val body: String,
|
||||
@SerialName("created_at") val createdAt: Long,
|
||||
)
|
||||
|
||||
// Frames of GET /v1/sessions/{key}/events; "type" is the discriminator.
|
||||
// The stream opens with the session's recorded turns replayed before the
|
||||
// live feed, so a screen (re)builds its transcript from the replay.
|
||||
// Unknown types fail the per-frame parse and are skipped, so the API can grow
|
||||
// without breaking older clients.
|
||||
@Serializable
|
||||
sealed interface ClawEvent {
|
||||
@Serializable
|
||||
@SerialName("user_message")
|
||||
data class UserMessage(val text: String = "") : ClawEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("thinking_delta")
|
||||
data class ThinkingDelta(val text: String = "") : ClawEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("text_delta")
|
||||
data class TextDelta(val text: String = "") : ClawEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("tool_start")
|
||||
data class ToolStart(val name: String = "") : ClawEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("tool_result")
|
||||
data class ToolResult(
|
||||
val name: String = "",
|
||||
@SerialName("is_error") val isError: Boolean = false,
|
||||
val summary: String? = null,
|
||||
// Command tools answer with a process exit code and the shell
|
||||
// line; both ride only on the bash-like tools.
|
||||
val code: Long? = null,
|
||||
val command: String? = null,
|
||||
) : ClawEvent
|
||||
|
||||
// The reply is null when the turn ended without producing text —
|
||||
// a failed turn, mostly. Nullable on purpose: a dropped frame would
|
||||
// hide exactly the turns a viewer needs the outcome tag for.
|
||||
@Serializable
|
||||
@SerialName("turn_done")
|
||||
data class TurnDone(val reply: String? = null, val outcome: String? = null) : ClawEvent
|
||||
|
||||
// A file the agent produced. The bytes are not on the stream; they
|
||||
// live at GET /v1/attachments/{id} behind the same bearer token.
|
||||
@Serializable
|
||||
@SerialName("attachment")
|
||||
data class Attachment(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val media: String = "file",
|
||||
val size: Long = 0,
|
||||
val caption: String? = null,
|
||||
) : ClawEvent
|
||||
|
||||
// The agent asking the human: kind=secret wants a secret string,
|
||||
// kind=phone is approve/deny of a phone control action.
|
||||
@Serializable
|
||||
@SerialName("consent_request")
|
||||
data class ConsentRequest(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val summary: String,
|
||||
val detail: String,
|
||||
@SerialName("created_at") val createdAt: Long = 0,
|
||||
) : ClawEvent
|
||||
|
||||
// A question settled — by this surface or another one; the card
|
||||
// flips on it whichever way the answer went.
|
||||
@Serializable
|
||||
@SerialName("consent_resolved")
|
||||
data class ConsentResolved(val id: String, val granted: Boolean) : ClawEvent
|
||||
}
|
||||
|
||||
// GET /v1/consents — the pending questions, oldest first.
|
||||
@Serializable
|
||||
data class ConsentsPage(val consents: List<ConsentInfo> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class ConsentInfo(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val summary: String,
|
||||
val detail: String,
|
||||
@SerialName("created_at") val createdAt: Long,
|
||||
)
|
||||
|
||||
// POST /v1/consents/{id} — the kind decides the body.
|
||||
@Serializable
|
||||
data class SecretConsentAnswer(val secret: String)
|
||||
|
||||
@Serializable
|
||||
data class PhoneConsentAnswer(val grant: Boolean)
|
||||
|
||||
// GET /v1/phone/commands — the control queue for this device, oldest
|
||||
// first. The result POST doubles as the ack: until it lands the claw
|
||||
// keeps the command queued and the worker repeats it.
|
||||
@Serializable
|
||||
data class CommandPage(val commands: List<PhoneCommand> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class PhoneCommand(
|
||||
val id: String,
|
||||
val action: String,
|
||||
val args: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("created_at") val createdAt: Long = 0,
|
||||
)
|
||||
|
||||
// POST /v1/phone/commands/{id}/result
|
||||
@Serializable
|
||||
data class CommandResult(val ok: Boolean, val detail: String? = null)
|
||||
|
||||
// GET /v1/agent — the agent's presentation identity, shared with the web
|
||||
// UI. Unset text fields are absent on the wire (the claw skips nulls), so
|
||||
// every field defaults; the images report presence only, their bytes live
|
||||
// at GET /v1/agent/{kind}.
|
||||
@Serializable
|
||||
data class AgentIdentity(
|
||||
val name: String? = null,
|
||||
val mark: String? = null,
|
||||
val initials: String? = null,
|
||||
val tagline: String? = null,
|
||||
val signoff: String? = null,
|
||||
val avatar: Boolean = false,
|
||||
val logo: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package rs.rhema.phone.notif
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import rs.rhema.phone.MainActivity
|
||||
import rs.rhema.phone.R
|
||||
|
||||
class Notifier(private val context: Context) {
|
||||
|
||||
fun fallbackTitle(): String = context.getString(R.string.notif_default_title)
|
||||
|
||||
fun ensureChannel() =
|
||||
ensureChannel(CHANNEL_ID, R.string.channel_name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
|
||||
// The claw's notification payload carries no session key, so the tap opens
|
||||
// the app root (session list) rather than a specific chat. id = hash of the
|
||||
// claw id: stable per notification, so re-shows collapse instead of stack.
|
||||
fun post(notificationId: Int, title: String, body: String) {
|
||||
notifyOn(CHANNEL_ID, notificationId, title, body)
|
||||
}
|
||||
|
||||
// The alert path (claw phone commands): heads-up and sound on, against
|
||||
// the poll channel's quiet delivery. Answers false when the runtime
|
||||
// notification grant is missing, so the caller reports that back
|
||||
// instead of pretending delivery.
|
||||
fun postAlert(notificationId: Int, title: String, body: String): Boolean =
|
||||
notifyOn(ALERT_CHANNEL_ID, notificationId, title, body)
|
||||
|
||||
// False when POST_NOTIFICATIONS is ungranted (API 33+): nothing can be
|
||||
// posted without it, and callers still advance their own state (see
|
||||
// the workers).
|
||||
private fun notifyOn(channel: String, notificationId: Int, title: String, body: String): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (channel == ALERT_CHANNEL_ID) {
|
||||
ensureChannel(
|
||||
ALERT_CHANNEL_ID,
|
||||
R.string.alert_channel_name,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
} else {
|
||||
ensureChannel()
|
||||
}
|
||||
val tap = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(R.drawable.ic_rhema)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(tap)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
?.notify(notificationId, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ensureChannel(id: String, nameRes: Int, importance: Int) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.createNotificationChannel(NotificationChannel(id, context.getString(nameRes), importance))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "rhema"
|
||||
|
||||
// The alert channel exists for immediacy: high importance posts
|
||||
// heads-up with sound, where the default channel stays quiet.
|
||||
const val ALERT_CHANNEL_ID = "rhema-alert"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package rs.rhema.phone.sensors
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlarmManager
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.UsageEvents
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.os.CancellationSignal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** One location observation: where, how precisely, and how old. */
|
||||
data class LocationFix(
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val accuracyM: Double?,
|
||||
val provider: String?,
|
||||
val ageS: Long,
|
||||
)
|
||||
|
||||
/** The app the phone last brought to the foreground, and how long ago. */
|
||||
data class ForegroundApp(
|
||||
val pkg: String,
|
||||
val label: String?,
|
||||
val ageS: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* The status sensors the phone reports, one function each. Every reader
|
||||
* is nullable by contract: a sensor this phone cannot or may not answer
|
||||
* (a permission the human has not granted, a provider switched off,
|
||||
* nothing to report) produces no value, and no value never produces an
|
||||
* ingest post. The status worker calls these on its run; the settings
|
||||
* screen calls the two grant checks to explain what is switched off.
|
||||
*/
|
||||
object PhoneSensors {
|
||||
|
||||
/** The age beyond which a location fix is too stale to report. */
|
||||
private const val MAX_FIX_AGE_MS = 30 * 60 * 1000L
|
||||
|
||||
/** How long a fresh-fix request may take before the fallback wins. */
|
||||
private const val FIX_TIMEOUT_MS = 20_000L
|
||||
|
||||
/** The look-back window for "the app currently in the foreground". */
|
||||
private const val USAGE_LOOKBACK_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
// The foreground transition's event type: ACTIVITY_RESUMED renamed
|
||||
// MOVE_TO_FOREGROUND at API 29, same value, so the inlined constant
|
||||
// reads correctly on every supported level.
|
||||
@Suppress("InlinedApi")
|
||||
private val FOREGROUND_EVENT = UsageEvents.Event.ACTIVITY_RESUMED
|
||||
|
||||
/** Location (fine or coarse) is a runtime permission: granted here, or no fixes. */
|
||||
fun hasLocation(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Usage access is a special-app grant (a settings toggle, like DND access). */
|
||||
fun hasUsageAccess(context: Context): Boolean {
|
||||
val appOps = context.getSystemService(AppOpsManager::class.java) ?: return false
|
||||
val mode = @Suppress("DEPRECATION") appOps.checkOpNoThrow(
|
||||
AppOpsManager.OPSTR_GET_USAGE_STATS,
|
||||
Process.myUid(),
|
||||
context.packageName,
|
||||
)
|
||||
return mode == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh fix from the best enabled provider, falling back to
|
||||
* [lastKnownFix] when no provider can answer inside [FIX_TIMEOUT_MS].
|
||||
* The snapshot pass prefers this over a last-known read: last-known
|
||||
* is whatever another app happened to leave behind, which on a
|
||||
* stationary phone goes stale within minutes and reports where the
|
||||
* phone was, not where it is.
|
||||
*/
|
||||
suspend fun currentFix(context: Context): LocationFix? {
|
||||
if (!hasLocation(context)) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
// Fused exists from API 31 and is the one provider that answers
|
||||
// indoors and out; below that, gps then network.
|
||||
val provider = listOf(
|
||||
if (Build.VERSION.SDK_INT >= 31) LocationManager.FUSED_PROVIDER else null,
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
).filterNotNull().firstOrNull { candidate ->
|
||||
runCatching { manager.isProviderEnabled(candidate) }.getOrDefault(false)
|
||||
} ?: return lastKnownFix(context)
|
||||
|
||||
val signal = CancellationSignal()
|
||||
val location = withTimeoutOrNull(FIX_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine<Location?> { continuation ->
|
||||
val settled = AtomicBoolean(false)
|
||||
continuation.invokeOnCancellation {
|
||||
settled.set(true)
|
||||
signal.cancel()
|
||||
}
|
||||
val callback = androidx.core.util.Consumer<Location> { location ->
|
||||
// The timeout abandons this coroutine but not the
|
||||
// callback: a late answer must not resume it twice.
|
||||
// A null answer is the provider's "no fix", which
|
||||
// falls through to the last-known fallback.
|
||||
if (settled.compareAndSet(false, true)) {
|
||||
continuation.resume(location)
|
||||
}
|
||||
}
|
||||
try {
|
||||
LocationManagerCompat.getCurrentLocation(
|
||||
manager,
|
||||
provider,
|
||||
signal,
|
||||
ContextCompat.getMainExecutor(context),
|
||||
callback,
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
// The grant can be revoked between the check at the
|
||||
// top of currentFix and this request; that read then
|
||||
// belongs to the last-known fallback, not a crash.
|
||||
if (settled.compareAndSet(false, true)) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return location?.let { fix ->
|
||||
LocationFix(
|
||||
lat = fix.latitude,
|
||||
lon = fix.longitude,
|
||||
accuracyM = fix.accuracy.takeIf { it > 0f }?.toDouble(),
|
||||
provider = fix.provider,
|
||||
ageS = (System.currentTimeMillis() - fix.time) / 1000,
|
||||
)
|
||||
} ?: lastKnownFix(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent fix any enabled provider still holds, or null
|
||||
* when location is off or every fix is older than [MAX_FIX_AGE_MS].
|
||||
* The fallback read behind [currentFix], and the answer when no
|
||||
* provider can produce a fresh fix in time.
|
||||
*/
|
||||
fun lastKnownFix(context: Context): LocationFix? {
|
||||
if (!hasLocation(context)) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val now = System.currentTimeMillis()
|
||||
val fix = try {
|
||||
manager.getProviders(true)
|
||||
.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||
.filter { now - it.time <= MAX_FIX_AGE_MS }
|
||||
.maxByOrNull { it.time }
|
||||
} catch (_: SecurityException) {
|
||||
// The grant can be revoked between the check above and this read.
|
||||
null
|
||||
} ?: return null
|
||||
return LocationFix(
|
||||
lat = fix.latitude,
|
||||
lon = fix.longitude,
|
||||
accuracyM = fix.accuracy.takeIf { it > 0f }?.toDouble(),
|
||||
provider = fix.provider,
|
||||
ageS = (now - fix.time) / 1000,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The app last brought to the foreground, from the usage stream.
|
||||
* Needs the usage-access grant; without it this is null and the
|
||||
* app topic stays silent. This app's own activity is skipped: the
|
||||
* status run itself must not make Rhema the answer.
|
||||
*/
|
||||
fun foregroundApp(context: Context): ForegroundApp? {
|
||||
if (!hasUsageAccess(context)) return null
|
||||
val usage = context.getSystemService(UsageStatsManager::class.java) ?: return null
|
||||
val now = System.currentTimeMillis()
|
||||
val events = usage.queryEvents(now - USAGE_LOOKBACK_MS, now)
|
||||
val event = UsageEvents.Event()
|
||||
var pkg: String? = null
|
||||
var lastResume = 0L
|
||||
while (events.hasNextEvent()) {
|
||||
events.getNextEvent(event)
|
||||
if (event.eventType == FOREGROUND_EVENT && event.packageName != context.packageName) {
|
||||
pkg = event.packageName
|
||||
lastResume = event.timeStamp
|
||||
}
|
||||
}
|
||||
pkg ?: return null
|
||||
val label = runCatching {
|
||||
val pm = context.packageManager
|
||||
pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString()
|
||||
}.getOrNull()
|
||||
return ForegroundApp(pkg, label, (now - lastResume) / 1000)
|
||||
}
|
||||
|
||||
/** The connected wifi network's name, when the system will tell. */
|
||||
@Suppress("DEPRECATION")
|
||||
fun wifiSsid(context: Context): String? {
|
||||
// Reading an SSID is treated as location by the OS: without the
|
||||
// grant the answer is a placeholder, so do not post it.
|
||||
if (!hasLocation(context)) return null
|
||||
val wifi = context.getSystemService(WifiManager::class.java) ?: return null
|
||||
val info = wifi.connectionInfo ?: return null
|
||||
return info.ssid
|
||||
?.removeSurrounding("\"")
|
||||
?.takeIf { ssid ->
|
||||
ssid.isNotBlank() && !ssid.equals("<unknown ssid>", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's next alarm as set in the clock app, epoch millis. */
|
||||
fun nextAlarm(context: Context): Long? =
|
||||
context
|
||||
.getSystemService(AlarmManager::class.java)
|
||||
?.nextAlarmClock
|
||||
?.triggerTime
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
/** The device's identity fingerprint: changes on upgrade or rename. */
|
||||
fun deviceFingerprint(context: Context): String {
|
||||
val version = runCatching { appVersion(context) }.getOrNull().orEmpty()
|
||||
return listOf(Build.MANUFACTURER, Build.MODEL, Build.VERSION.RELEASE, Build.VERSION.SDK_INT, version)
|
||||
.joinToString("|")
|
||||
}
|
||||
|
||||
/** `<manufacturer> <model>`, the label the claw's fleet rows lead with. */
|
||||
fun deviceName(): String = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||
|
||||
/** This install's own version name, e.g. "0.1.0". */
|
||||
fun appVersion(context: Context): String {
|
||||
val pm = context.packageManager
|
||||
@Suppress("DEPRECATION")
|
||||
val info = pm.getPackageInfo(context.packageName, 0)
|
||||
return info.versionName ?: ""
|
||||
}
|
||||
|
||||
/** Metres between a fix and a previously posted one, 0 when either is absent. */
|
||||
fun metresBetween(fromLat: Double?, fromLon: Double?, to: LocationFix): Float? {
|
||||
if (fromLat == null || fromLon == null) return null
|
||||
val results = FloatArray(2)
|
||||
Location.distanceBetween(fromLat, fromLon, to.lat, to.lon, results)
|
||||
return results[0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import rs.rhema.phone.R
|
||||
import rs.rhema.phone.chat.ChatController
|
||||
import rs.rhema.phone.data.AgentIdentityCache
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
// The transcript screen, the design's chat panel as a phone screen:
|
||||
// avatar rows per turn (the tool cards with their one-sided accent
|
||||
// border, dim collapsible thinking, the outcome tag), and a composer
|
||||
// with the round accent send button. The claw owns the conversation —
|
||||
// this renders what the event stream delivered, nothing more.
|
||||
@Composable
|
||||
fun ChatScreen(container: AppContainer, sessionKey: String, onBack: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val controller = remember(sessionKey) { ChatController(container.api, container.sessionCache, scope, sessionKey) }
|
||||
// The SSE connection lives exactly as long as this screen is composed.
|
||||
DisposableEffect(sessionKey) {
|
||||
controller.start()
|
||||
onDispose { controller.stop() }
|
||||
}
|
||||
BackHandler { onBack() }
|
||||
|
||||
var draft by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
// The claw's presentation identity: the avatar picture (when one is
|
||||
// set), the initials glyph otherwise, and the agent's name on every
|
||||
// turn — the same face the web UI shows.
|
||||
val face = rememberAgentFace(container)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ChatHeader(sessionKey, controller.connection, face, onBack)
|
||||
HorizontalDivider(color = JadeiteBorder)
|
||||
|
||||
// Reversed layout: index 0 is the newest turn, pinned to the
|
||||
// bottom edge. A streaming reply grows against that anchor, so
|
||||
// the newest text stays on screen without scroll bookkeeping,
|
||||
// and a reader scrolled into history is never yanked back.
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
items(turnGroups(controller.items).asReversed(), key = { it.key }) { group ->
|
||||
TurnGroupRow(group, container, controller, face)
|
||||
}
|
||||
}
|
||||
|
||||
controller.sendError?.let { err ->
|
||||
Text(
|
||||
stringResource(R.string.chat_send_failed, err),
|
||||
color = JadeiteError,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text(stringResource(R.string.chat_message_hint)) },
|
||||
maxLines = 4,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = JadeiteAccent,
|
||||
unfocusedBorderColor = JadeiteBorder,
|
||||
focusedContainerColor = JadeiteSunken,
|
||||
unfocusedContainerColor = JadeiteSunken,
|
||||
cursorColor = JadeiteAccentText,
|
||||
focusedTextColor = JadeiteText,
|
||||
unfocusedTextColor = JadeiteText,
|
||||
),
|
||||
)
|
||||
SendButton(
|
||||
enabled = !controller.sending && draft.isNotBlank(),
|
||||
) {
|
||||
val text = draft
|
||||
draft = ""
|
||||
scope.launch {
|
||||
if (!controller.send(text)) draft = text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionKey: String,
|
||||
conn: ChatController.Conn,
|
||||
face: AgentFace,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.chat_back),
|
||||
tint = JadeiteText2,
|
||||
)
|
||||
}
|
||||
AppAvatar(face.glyph, accent = face.image == null, size = 34.dp, image = face.image)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
sessionKey.removePrefix("interactive:"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
StatusDot(tone = connTone(conn))
|
||||
Text(
|
||||
connText(conn),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (connTone(conn) == DotTone.Error) JadeiteError else JadeiteText3,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connTone(conn: ChatController.Conn): DotTone = when (conn) {
|
||||
ChatController.Conn.Connecting -> DotTone.Warn
|
||||
ChatController.Conn.Live -> DotTone.Ok
|
||||
ChatController.Conn.Reconnecting -> DotTone.Error
|
||||
is ChatController.Conn.Failed -> DotTone.Error
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun connText(conn: ChatController.Conn): String = when (conn) {
|
||||
ChatController.Conn.Connecting -> stringResource(R.string.chat_status_connecting)
|
||||
ChatController.Conn.Live -> stringResource(R.string.chat_status_live)
|
||||
ChatController.Conn.Reconnecting -> stringResource(R.string.chat_status_reconnecting)
|
||||
is ChatController.Conn.Failed ->
|
||||
if (conn.code == 401) {
|
||||
stringResource(R.string.chat_status_unauthorized)
|
||||
} else {
|
||||
stringResource(R.string.chat_status_failed, conn.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (enabled) JadeiteAccent else JadeiteElevated,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send),
|
||||
tint = if (enabled) JadeiteOnAccent else JadeiteText3,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One turn as rendered: a user message, or the agent's stack of
|
||||
// thinking / tool cards / reply / outcome under one avatar. The flat
|
||||
// item list folds here — consecutive agent items belong to one turn.
|
||||
private sealed interface TurnGroup {
|
||||
val key: String
|
||||
|
||||
data class User(val id: Long, val text: String) : TurnGroup {
|
||||
override val key: String = "u$id"
|
||||
}
|
||||
|
||||
data class Agent(val items: MutableList<ChatController.Item>) : TurnGroup {
|
||||
override val key: String = "a${items.first().id}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun turnGroups(items: List<ChatController.Item>): List<TurnGroup> {
|
||||
val groups = mutableListOf<TurnGroup>()
|
||||
for (item in items) {
|
||||
when (item) {
|
||||
is ChatController.UserMessage -> groups += TurnGroup.User(item.id, item.text)
|
||||
else -> {
|
||||
val last = groups.lastOrNull()
|
||||
if (last is TurnGroup.Agent) last.items += item else groups += TurnGroup.Agent(mutableListOf(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TurnGroupRow(
|
||||
group: TurnGroup,
|
||||
container: AppContainer,
|
||||
controller: ChatController,
|
||||
face: AgentFace,
|
||||
) {
|
||||
when (group) {
|
||||
is TurnGroup.User -> Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// The reader's own words: a right-anchored bubble, elevated
|
||||
// one step and marked on its trailing edge — no label or
|
||||
// avatar, the side says whose it is.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(JadeiteElevated, MaterialTheme.shapes.small),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Embedded pastes render as chips, not payloads; the
|
||||
// text around them stays verbatim.
|
||||
var chip = 0
|
||||
pasteParts(group.text).forEach { part ->
|
||||
when (part) {
|
||||
is PastePart.Text -> Text(part.text, style = MaterialTheme.typography.bodyLarge)
|
||||
is PastePart.Paste -> {
|
||||
chip += 1
|
||||
PasteChip(chipLabel(part.payload, chip))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AccentBar(JadeiteAccent)
|
||||
}
|
||||
}
|
||||
is TurnGroup.Agent -> Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
AppAvatar(face.glyph, accent = face.image == null, image = face.image)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
face.name ?: stringResource(R.string.chat_agent),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (group.items.any { it is ChatController.Reply && it.streaming }) {
|
||||
JadeiteAccentText
|
||||
} else {
|
||||
JadeiteText2
|
||||
},
|
||||
)
|
||||
// The design stacks a turn as thinking, tool cards, then the
|
||||
// reply and its outcome — stream order interleaves them, the
|
||||
// rendering does not.
|
||||
group.items.sortedBy { turnPriority(it) }.forEach { item ->
|
||||
when (item) {
|
||||
is ChatController.Thinking -> ThinkingBlock(item)
|
||||
is ChatController.ToolCall ->
|
||||
if (isShellTool(item.name)) ShellRow(item) else ToolCard(item)
|
||||
is ChatController.Attachment -> AttachmentCard(item, container)
|
||||
is ChatController.Consent -> ConsentCard(item, controller)
|
||||
is ChatController.Reply -> ReplyBlock(item)
|
||||
is ChatController.Outcome -> OutcomeTag(item.outcome)
|
||||
is ChatController.UserMessage -> Unit // never grouped here
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render order inside one agent turn: thinking, tools, attachments,
|
||||
// questions, reply, outcome.
|
||||
private fun turnPriority(item: ChatController.Item): Int = when (item) {
|
||||
is ChatController.Thinking -> 0
|
||||
is ChatController.ToolCall -> 1
|
||||
is ChatController.Attachment -> 2
|
||||
is ChatController.Consent -> 3
|
||||
is ChatController.Reply -> 4
|
||||
is ChatController.Outcome -> 5
|
||||
is ChatController.UserMessage -> 6
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingBlock(item: ChatController.Thinking) {
|
||||
// Dim and collapsible: it is the agent's scratch, not its answer.
|
||||
var expanded by remember(item.id) { mutableStateOf(false) }
|
||||
val text = if (expanded || item.text.length <= 120) item.text else "${item.text.take(120)}…"
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"thinking",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolCard(item: ChatController.ToolCall) {
|
||||
val tint = when {
|
||||
item.running -> JadeiteText3
|
||||
item.isError -> JadeiteError
|
||||
else -> JadeiteOk
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||
) {
|
||||
AccentBar(tint)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (item.running) "…" else if (item.isError) "✗" else "✓",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = tint,
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
item.summary?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText3,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Command rows render as shell lines, not glyph rows (the desktop's
|
||||
* shell_line); run_command is the bash tool's other name. */
|
||||
private fun isShellTool(name: String): Boolean = name == "bash" || name == "run_command"
|
||||
|
||||
@Composable
|
||||
private fun ShellRow(item: ChatController.ToolCall) {
|
||||
val tint = when {
|
||||
item.running -> JadeiteText3
|
||||
item.exitCode == 0L && !item.isError -> JadeiteOk
|
||||
else -> JadeiteError
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||
) {
|
||||
AccentBar(tint)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// A shell line reads as the command it ran: dim $ prefix, the
|
||||
// command, the exit code at its end.
|
||||
Text(
|
||||
"$",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
Text(
|
||||
item.command ?: "…",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
when {
|
||||
item.running -> Unit
|
||||
item.exitCode != null -> Text(
|
||||
"[${item.exitCode}]",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = JadeiteMono,
|
||||
color = if (item.exitCode == 0L) JadeiteOk else JadeiteError,
|
||||
)
|
||||
item.isError -> Text(
|
||||
"✗",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyBlock(item: ChatController.Reply) {
|
||||
AgentMarkdown(
|
||||
text = item.text,
|
||||
streaming = item.streaming,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutcomeTag(outcome: String) {
|
||||
Text(
|
||||
outcome,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = if (outcome.equals("Failed", ignoreCase = true)) JadeiteError else JadeiteText3,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasteChip(label: String) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText3,
|
||||
modifier = Modifier
|
||||
.background(JadeiteSunken, MaterialTheme.shapes.extraSmall)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// --- embedded pastes --------------------------------------------------
|
||||
// The wire form a long paste travels in: MARK, payload, MARK — one
|
||||
// logical unit inside otherwise plain text (rhema-core's paste module
|
||||
// is the definition; these are its client-side shape). A mark with no
|
||||
// partner is literal text.
|
||||
|
||||
private const val PASTE_MARK = '\uFFFC'
|
||||
|
||||
/** One scanned piece of embedded-form text. */
|
||||
private sealed interface PastePart {
|
||||
data class Text(val text: String) : PastePart
|
||||
data class Paste(val payload: String) : PastePart
|
||||
}
|
||||
|
||||
/** Splits embedded-form text into its parts, left to right. A mark with
|
||||
* no partner is literal text; malformed input never throws. */
|
||||
private fun pasteParts(text: String): List<PastePart> {
|
||||
val parts = mutableListOf<PastePart>()
|
||||
var rest = text
|
||||
while (true) {
|
||||
val start = rest.indexOf(PASTE_MARK)
|
||||
if (start < 0) break
|
||||
val after = rest.substring(start + 1)
|
||||
val end = after.indexOf(PASTE_MARK)
|
||||
if (end < 0) {
|
||||
parts += PastePart.Text(rest.substring(0, start + 1))
|
||||
rest = after
|
||||
continue
|
||||
}
|
||||
if (start > 0) parts += PastePart.Text(rest.substring(0, start))
|
||||
parts += PastePart.Paste(after.substring(0, end))
|
||||
rest = after.substring(end + 1)
|
||||
}
|
||||
if (rest.isNotEmpty()) parts += PastePart.Text(rest)
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Rust `str::lines`: \n-split, and a trailing newline ends the last
|
||||
* line instead of opening an empty one. */
|
||||
private fun lineCount(text: String): Int {
|
||||
if (text.isEmpty()) return 0
|
||||
val lines = text.split('\n')
|
||||
return if (lines.last().isEmpty()) lines.size - 1 else lines.size
|
||||
}
|
||||
|
||||
/** A chip's label: `▣1 5L` — the anchor's one-based id and the
|
||||
* payload's line count. */
|
||||
private fun chipLabel(paste: String, chip: Int): String = "▣$chip ${lineCount(paste)}L"
|
||||
|
||||
// --- attachments ------------------------------------------------------
|
||||
|
||||
// Only small images preview inline: there is no image loader in the
|
||||
// app, so the preview is a BitmapFactory decode of the fetched bytes.
|
||||
private const val INLINE_IMAGE_MAX_BYTES = 2L * 1024 * 1024
|
||||
|
||||
private sealed interface AttachState {
|
||||
data object Idle : AttachState
|
||||
data object Loading : AttachState
|
||||
data class Ready(val uri: Uri, val preview: ImageBitmap?, val note: String? = null) : AttachState
|
||||
data class Failed(val message: String) : AttachState
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentCard(item: ChatController.Attachment, container: AppContainer) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Templates resolve in composition, not at callback time: a lookup
|
||||
// through LocalContext.current freezes the locale of the first
|
||||
// composition.
|
||||
val failedTemplate = stringResource(R.string.chat_attachment_failed)
|
||||
val openFailed = stringResource(R.string.chat_attachment_open_failed)
|
||||
var state by remember(item.attachmentId) { mutableStateOf<AttachState>(AttachState.Idle) }
|
||||
|
||||
// One fetch: bytes → cache file → shareable uri; small images also
|
||||
// decode to the inline preview. open hands the uri to ACTION_VIEW.
|
||||
suspend fun download(open: Boolean) {
|
||||
state = AttachState.Loading
|
||||
when (val result = container.api.attachment(item.attachmentId)) {
|
||||
is ClawResult.Ok -> {
|
||||
val prepared = withContext(Dispatchers.IO) {
|
||||
val uri = saveAttachment(context, item.attachmentId, item.name, result.value)
|
||||
when {
|
||||
uri == null -> AttachState.Failed(
|
||||
failedTemplate.format("save failed"),
|
||||
)
|
||||
item.media == "image" && result.value.size <= INLINE_IMAGE_MAX_BYTES -> {
|
||||
val preview = BitmapFactory.decodeByteArray(
|
||||
result.value,
|
||||
0,
|
||||
result.value.size,
|
||||
)?.asImageBitmap()
|
||||
AttachState.Ready(uri, preview)
|
||||
}
|
||||
else -> AttachState.Ready(uri, preview = null)
|
||||
}
|
||||
}
|
||||
state = prepared
|
||||
if (prepared is AttachState.Ready && open && !openAttachment(context, prepared.uri)) {
|
||||
state = prepared.copy(note = openFailed)
|
||||
}
|
||||
}
|
||||
is ClawResult.HttpError ->
|
||||
state = AttachState.Failed(failedTemplate.format("HTTP ${result.code}"))
|
||||
is ClawResult.Network ->
|
||||
state = AttachState.Failed(failedTemplate.format(result.message))
|
||||
}
|
||||
}
|
||||
|
||||
// Small images fetch themselves on first composition; everything else
|
||||
// waits for the download row.
|
||||
LaunchedEffect(item.attachmentId) {
|
||||
if (item.media == "image" && item.size in 1L..INLINE_IMAGE_MAX_BYTES && state == AttachState.Idle) {
|
||||
download(open = false)
|
||||
}
|
||||
}
|
||||
|
||||
val current = state
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||
) {
|
||||
AccentBar(
|
||||
when (current) {
|
||||
AttachState.Loading -> JadeiteText3
|
||||
is AttachState.Failed -> JadeiteError
|
||||
else -> JadeiteOk
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
item.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(
|
||||
formatBytes(item.size),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
item.caption?.takeIf { it.isNotBlank() }?.let { caption ->
|
||||
Text(caption, style = MaterialTheme.typography.bodySmall, color = JadeiteText2)
|
||||
}
|
||||
if (current is AttachState.Ready) {
|
||||
current.preview?.let { preview ->
|
||||
Image(
|
||||
bitmap = preview,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 240.dp),
|
||||
)
|
||||
}
|
||||
current.note?.let { note ->
|
||||
Text(note, style = MaterialTheme.typography.bodySmall, color = JadeiteError)
|
||||
}
|
||||
}
|
||||
if (current is AttachState.Failed) {
|
||||
Text(current.message, style = MaterialTheme.typography.bodySmall, color = JadeiteError)
|
||||
}
|
||||
if (current is AttachState.Loading) {
|
||||
Text(
|
||||
stringResource(R.string.chat_attachment_downloading),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
when (current) {
|
||||
is AttachState.Ready -> stringResource(R.string.chat_attachment_open)
|
||||
else -> stringResource(R.string.chat_attachment_download)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteAccentText,
|
||||
modifier = Modifier.clickable {
|
||||
when (val s = state) {
|
||||
is AttachState.Ready -> {
|
||||
if (!openAttachment(context, s.uri)) {
|
||||
state = s.copy(note = openFailed)
|
||||
}
|
||||
}
|
||||
else -> scope.launch { download(open = true) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves the bytes under cache/attachments/ and answers the shareable
|
||||
* content:// uri; other apps can read the file only through the
|
||||
* FileProvider mapping, so the name (its extension drives the mime)
|
||||
* must survive the save. Null when the write failed. */
|
||||
private fun saveAttachment(context: Context, id: String, name: String, bytes: ByteArray): Uri? {
|
||||
val dir = File(context.cacheDir, "attachments")
|
||||
if (!dir.exists() && !dir.mkdirs()) return null
|
||||
val safe = name.substringAfterLast('/').substringAfterLast('\\').trim().ifBlank { "attachment" }
|
||||
return try {
|
||||
val file = File(dir, "$id-$safe")
|
||||
file.writeBytes(bytes)
|
||||
FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Hands a saved attachment to another app. False when nothing on the
|
||||
* phone opens it — the card says so, and the file stays for a retry. */
|
||||
private fun openAttachment(context: Context, uri: Uri): Boolean = try {
|
||||
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, mime)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
|
||||
/** B/KB/MB, integer — the mono token beside the file name. */
|
||||
private fun formatBytes(size: Long): String = when {
|
||||
size < 1024 -> "$size B"
|
||||
size < 1024 * 1024 -> "${size / 1024} KB"
|
||||
else -> "${size / (1024 * 1024)} MB"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsentCard(item: ChatController.Consent, controller: ChatController) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var secret by remember(item.id) { mutableStateOf("") }
|
||||
var busy by remember(item.id) { mutableStateOf(false) }
|
||||
var failure by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
|
||||
// An open question glows warn; a settled card keeps only its tone.
|
||||
val tint = when (item.granted) {
|
||||
null -> JadeiteWarn
|
||||
true -> JadeiteOk
|
||||
false -> JadeiteText3
|
||||
}
|
||||
|
||||
fun answer(call: suspend () -> ClawResult<Unit>) {
|
||||
busy = true
|
||||
scope.launch {
|
||||
failure = when (val result = call()) {
|
||||
is ClawResult.Ok -> null
|
||||
is ClawResult.HttpError -> "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||
is ClawResult.Network -> result.message
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(JadeiteSurface, MaterialTheme.shapes.extraSmall),
|
||||
) {
|
||||
AccentBar(tint)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AppBadge(item.kind, tint = tint)
|
||||
Text(
|
||||
item.summary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
Text(item.detail, style = MaterialTheme.typography.bodySmall, color = JadeiteText2)
|
||||
when (item.granted) {
|
||||
true -> Text(
|
||||
"✓ " + stringResource(R.string.consent_granted),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteOk,
|
||||
)
|
||||
false -> Text(
|
||||
"✗ " + stringResource(R.string.consent_denied),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
null -> {
|
||||
if (item.kind == "secret") {
|
||||
OutlinedTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(R.string.consent_secret_hint)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !busy,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = JadeiteAccent,
|
||||
unfocusedBorderColor = JadeiteBorder,
|
||||
focusedContainerColor = JadeiteSunken,
|
||||
unfocusedContainerColor = JadeiteSunken,
|
||||
cursorColor = JadeiteAccentText,
|
||||
focusedTextColor = JadeiteText,
|
||||
unfocusedTextColor = JadeiteText,
|
||||
),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (item.kind == "secret") {
|
||||
Button(
|
||||
enabled = !busy && secret.isNotBlank(),
|
||||
onClick = { answer { controller.answerSecret(item.consentId, secret) } },
|
||||
) {
|
||||
Text(stringResource(R.string.consent_submit))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
enabled = !busy,
|
||||
onClick = { answer { controller.grantPhone(item.consentId, true) } },
|
||||
) {
|
||||
Text(stringResource(R.string.consent_approve))
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
enabled = !busy,
|
||||
onClick = { answer { controller.denyConsent(item.consentId) } },
|
||||
) {
|
||||
Text(stringResource(R.string.consent_deny))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failure?.let { fail ->
|
||||
Text(
|
||||
stringResource(R.string.consent_answer_failed, fail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's face as this screen renders it: the display name (null
|
||||
// keeps the string resource's fallback), the initials glyph, and the
|
||||
// decoded avatar image. The glyph precedence is the web's: the claw's
|
||||
// `initials`, else the name's first letter, else "R".
|
||||
private data class AgentFace(val name: String?, val glyph: String, val image: ImageBitmap?)
|
||||
|
||||
@Composable
|
||||
private fun rememberAgentFace(container: AppContainer): AgentFace {
|
||||
val cache by container.identity.cached.collectAsState(AgentIdentityCache())
|
||||
var image by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
// Decoding off the main thread: an uploaded avatar can weigh the
|
||||
// claw's full 2 MB image cap.
|
||||
LaunchedEffect(cache.avatar) {
|
||||
image = cache.avatar?.let { bytes ->
|
||||
withContext(Dispatchers.Default) {
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
}
|
||||
}
|
||||
}
|
||||
val identity = cache.identity
|
||||
val name = identity?.name?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val glyph = identity?.initials?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: name?.take(1)
|
||||
?: "R"
|
||||
return AgentFace(name, glyph, image)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The design language's shared pieces, mirroring the web components:
|
||||
// one card voice, one label voice, one badge/dot/callout vocabulary.
|
||||
// Screens compose these; nothing here knows about the claw API.
|
||||
|
||||
/** The uppercase letterspaced section label — the repeated typographic voice. */
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = JadeiteText3,
|
||||
letterSpacing = MaterialTheme.typography.labelMedium.letterSpacing * 1.4f,
|
||||
)
|
||||
}
|
||||
|
||||
enum class DotTone { Ok, Warn, Error, Idle }
|
||||
|
||||
/** One honest state dot: ok, warn, error, or idle gray. */
|
||||
@Composable
|
||||
fun StatusDot(tone: DotTone, modifier: Modifier = Modifier) {
|
||||
val color = when (tone) {
|
||||
DotTone.Ok -> JadeiteOk
|
||||
DotTone.Warn -> JadeiteWarn
|
||||
DotTone.Error -> JadeiteError
|
||||
DotTone.Idle -> JadeiteText3
|
||||
}
|
||||
Box(modifier = modifier.size(8.dp).clip(RoundedCornerShape(50)).background(color))
|
||||
}
|
||||
|
||||
/** The pill: one small status or tag word, outlined in its tone. */
|
||||
@Composable
|
||||
fun AppBadge(text: String, modifier: Modifier = Modifier, tint: Color = JadeiteText3) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, tint),
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar square: the agent's picture when the claw has one, the
|
||||
* accent mark (or a neutral one) otherwise — the same precedence as the
|
||||
* web's `AgentAvatar`.
|
||||
*/
|
||||
@Composable
|
||||
fun AppAvatar(
|
||||
mark: String,
|
||||
accent: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 32.dp,
|
||||
image: ImageBitmap? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(size / 4)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape)
|
||||
.then(
|
||||
if (image == null) {
|
||||
Modifier
|
||||
.background(if (accent) JadeiteAccent else JadeiteElevated)
|
||||
.border(
|
||||
if (accent) 0.dp else 1.dp,
|
||||
if (accent) Color.Transparent else JadeiteBorderStrong,
|
||||
shape,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
mark,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (accent) JadeiteOnAccent else JadeiteText2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The page header: title, optional subtitle, trailing actions. */
|
||||
@Composable
|
||||
fun PageHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
subtitle?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = JadeiteText3)
|
||||
}
|
||||
}
|
||||
actions?.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one card: a titled container whose body rows separate by hairline.
|
||||
* Rows are the caller's job ([CardRow]); pass `first = true` to the
|
||||
* card's first row so it draws no divider.
|
||||
*/
|
||||
@Composable
|
||||
fun RhemaCard(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
meta: String? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = JadeiteSurface,
|
||||
border = BorderStroke(1.dp, JadeiteBorder),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
meta?.let {
|
||||
Text(
|
||||
it,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = JadeiteBorder)
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row inside a [RhemaCard]: name and explanation on the left, control
|
||||
* (or value) on the right. Hairline separation is drawn here except on
|
||||
* the card's first row.
|
||||
*/
|
||||
@Composable
|
||||
fun CardRow(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
description: String? = null,
|
||||
first: Boolean = false,
|
||||
control: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
if (!first) HorizontalDivider(color = JadeiteBorder)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = if (description == null) Alignment.CenterVertically else Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
description?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = JadeiteText3)
|
||||
}
|
||||
}
|
||||
control?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/** Tones of [AppCallout]: the inline note vocabulary. */
|
||||
enum class CalloutTone { Info, Ok, Warn, Error }
|
||||
|
||||
/** The toned inline callout — the "changes not applied" voice. */
|
||||
@Composable
|
||||
fun AppCallout(
|
||||
tone: CalloutTone,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
) {
|
||||
val tint = when (tone) {
|
||||
CalloutTone.Info -> JadeiteInfo
|
||||
CalloutTone.Ok -> JadeiteOk
|
||||
CalloutTone.Warn -> JadeiteWarn
|
||||
CalloutTone.Error -> JadeiteError
|
||||
}
|
||||
val mark = when (tone) {
|
||||
CalloutTone.Info -> "i"
|
||||
CalloutTone.Ok -> "✓"
|
||||
CalloutTone.Warn -> "↺"
|
||||
CalloutTone.Error -> "✗"
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = tint.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, JadeiteBorder),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
mark,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint,
|
||||
)
|
||||
Column {
|
||||
title?.let {
|
||||
Text(it, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, color = JadeiteText2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The design's select: a sunken field that opens a menu. Options are
|
||||
* (value, label) pairs; an empty-string value is a legitimate option
|
||||
* ("(provider default)"), which is why selection is by value.
|
||||
*/
|
||||
@Composable
|
||||
fun RhemaSelect(
|
||||
selected: String?,
|
||||
options: List<Pair<String, String>>,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val label = options.firstOrNull { it.first == selected }?.second ?: selected ?: ""
|
||||
Box(modifier) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = JadeiteSunken,
|
||||
border = BorderStroke(1.dp, JadeiteBorder),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = enabled) { open = true },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (selected == null) JadeiteText3 else JadeiteText,
|
||||
)
|
||||
Text("▾", style = MaterialTheme.typography.bodySmall, color = JadeiteText3)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = open,
|
||||
onDismissRequest = { open = false },
|
||||
containerColor = JadeiteElevated,
|
||||
) {
|
||||
options.forEach { (value, optionLabel) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
optionLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = if (value.isEmpty()) null else JadeiteMono,
|
||||
color = if (value == selected) JadeiteAccentText else JadeiteText2,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
open = false
|
||||
onSelect(value)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool-card accent bar: the design's one-sided border. The parent row must size itself (IntrinsicSize.Min) for the bar to span it. */
|
||||
@Composable
|
||||
fun AccentBar(color: Color, modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.width(3.dp).fillMaxHeight().background(color))
|
||||
}
|
||||
|
||||
/** A quiet icon action: transparent, dim, brightening on press. */
|
||||
@Composable
|
||||
fun GhostIcon(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) {
|
||||
Icon(icon, contentDescription = contentDescription, tint = JadeiteText3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.markdownDimens
|
||||
|
||||
// Agent replies are markdown; this renders them the way the web UI's
|
||||
// AppMarkdown does. The parser runs the GFM flavour: raw HTML in the
|
||||
// source stays literal, so model output cannot mount markup here.
|
||||
// Code and links pin Jadeite tokens; everything else follows the theme.
|
||||
// Headings step down from the Material display sizes: a chat reply is
|
||||
// not a document (the web styles ride the same scale).
|
||||
@Composable
|
||||
internal fun AgentMarkdown(text: String, modifier: Modifier = Modifier, streaming: Boolean = false) {
|
||||
// The cursor rides the source itself: the parser treats it as text at
|
||||
// the stream edge, so it lands where the reply currently ends.
|
||||
val base = MaterialTheme.typography.bodyLarge
|
||||
Markdown(
|
||||
if (streaming) text + "▊" else text,
|
||||
colors = markdownColor(
|
||||
codeBackground = JadeiteSunken,
|
||||
inlineCodeBackground = JadeiteSunken,
|
||||
dividerColor = JadeiteBorder,
|
||||
tableBackground = JadeiteSurface,
|
||||
),
|
||||
typography = markdownTypography(
|
||||
h1 = base.copy(fontWeight = FontWeight.Bold),
|
||||
h2 = base.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
h3 = base.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold),
|
||||
h4 = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||
h5 = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||
h6 = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText,
|
||||
),
|
||||
inlineCode = base.copy(fontFamily = JadeiteMono, color = JadeiteText),
|
||||
textLink = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = JadeiteAccentText,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
// The default 160.dp per cell makes a three-column table wider
|
||||
// than the phone screen; at 100.dp it fits and only wider tables
|
||||
// turn into their horizontal-scroll variant.
|
||||
dimens = markdownDimens(tableCellWidth = 100.dp, tableCellPadding = 8.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import rs.rhema.phone.R
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import rs.rhema.phone.net.PhoneInfo
|
||||
import rs.rhema.phone.sensors.PhoneSensors
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* The fleet management card: every device the claw has heard from,
|
||||
* this one marked, every other forgettable. The claw's
|
||||
* `GET`/`DELETE /v1/phones` are the whole story — the app holds no
|
||||
* private view of the fleet, so what this shows is what any client sees.
|
||||
*/
|
||||
@Composable
|
||||
fun PhonesCard(container: AppContainer) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var phones by remember { mutableStateOf<List<PhoneInfo>?>(null) }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
var forgetCandidate by remember { mutableStateOf<PhoneInfo?>(null) }
|
||||
var forgotName by remember { mutableStateOf<String?>(null) }
|
||||
val deviceId by container.settings.deviceIdFlow.collectAsState(initial = "")
|
||||
|
||||
fun load() {
|
||||
scope.launch {
|
||||
when (val result = container.api.phones()) {
|
||||
is ClawResult.Ok -> {
|
||||
phones = result.value.phones
|
||||
loadError = null
|
||||
}
|
||||
is ClawResult.HttpError ->
|
||||
loadError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||
is ClawResult.Network -> loadError = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { load() }
|
||||
|
||||
RhemaCard(title = stringResource(R.string.card_phones), meta = stringResource(R.string.phones_hint)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val currentError = loadError
|
||||
val list = phones
|
||||
when {
|
||||
currentError != null -> AppCallout(
|
||||
tone = CalloutTone.Error,
|
||||
text = stringResource(R.string.providers_error, currentError),
|
||||
)
|
||||
list == null -> Text(
|
||||
stringResource(R.string.phones_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
list.isEmpty() -> Text(
|
||||
stringResource(R.string.phones_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
else -> {
|
||||
forgotName?.let { name ->
|
||||
AppCallout(tone = CalloutTone.Ok, text = stringResource(R.string.phones_forgot_fmt, name))
|
||||
}
|
||||
list.forEachIndexed { index, phone ->
|
||||
val mine = phone.device == deviceId
|
||||
CardRow(
|
||||
title = phone.label ?: shortDeviceId(phone.device),
|
||||
description = fleetRowDescription(phone, mine),
|
||||
first = index == 0 && forgotName == null,
|
||||
) {
|
||||
if (mine) {
|
||||
AppBadge(stringResource(R.string.phones_this_device))
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { forgetCandidate = phone },
|
||||
) {
|
||||
Text(stringResource(R.string.phones_forget))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forgetCandidate?.let { candidate ->
|
||||
val name = candidate.label ?: shortDeviceId(candidate.device)
|
||||
AlertDialog(
|
||||
onDismissRequest = { forgetCandidate = null },
|
||||
title = { Text(stringResource(R.string.phones_forget_title, name)) },
|
||||
text = { Text(stringResource(R.string.phones_forget_text)) },
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
forgetCandidate = null
|
||||
scope.launch {
|
||||
when (val result = container.api.forgetPhone(candidate.device)) {
|
||||
is ClawResult.Ok -> {
|
||||
forgotName = name
|
||||
load()
|
||||
}
|
||||
is ClawResult.HttpError ->
|
||||
loadError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||
is ClawResult.Network -> loadError = result.message
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.phones_forget)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { forgetCandidate = null }) {
|
||||
Text(stringResource(R.string.providers_discard))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What this phone reports and what it cannot until the human flips a
|
||||
* grant: location (a runtime permission) and the two special-app
|
||||
* toggles Android requires for usage stats and DND control. The state
|
||||
* re-reads on every ON_RESUME, so coming back from the settings app
|
||||
* flips the row without a restart.
|
||||
*/
|
||||
@Composable
|
||||
fun ReportingCard() {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var resumeKey by remember { mutableIntStateOf(0) }
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) resumeKey++
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val locationOn = remember(resumeKey) { PhoneSensors.hasLocation(context) }
|
||||
val usageOn = remember(resumeKey) { PhoneSensors.hasUsageAccess(context) }
|
||||
val dndOn = remember(resumeKey) {
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
?.isNotificationPolicyAccessGranted == true
|
||||
}
|
||||
|
||||
RhemaCard(
|
||||
title = stringResource(R.string.card_reporting),
|
||||
meta = stringResource(R.string.reporting_hint),
|
||||
) {
|
||||
CardRow(
|
||||
title = stringResource(R.string.reporting_location_title),
|
||||
description = stringResource(R.string.reporting_location_desc),
|
||||
first = true,
|
||||
) {
|
||||
GrantControl(locationOn) { openAppSettings(context) }
|
||||
}
|
||||
CardRow(
|
||||
title = stringResource(R.string.reporting_usage_title),
|
||||
description = stringResource(R.string.reporting_usage_desc),
|
||||
) {
|
||||
GrantControl(usageOn) { openUsageAccessSettings(context) }
|
||||
}
|
||||
CardRow(
|
||||
title = stringResource(R.string.reporting_dnd_title),
|
||||
description = stringResource(R.string.reporting_dnd_desc),
|
||||
) {
|
||||
GrantControl(dndOn) {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** On reads as a quiet badge; off as a button into the system settings. */
|
||||
@Composable
|
||||
private fun GrantControl(on: Boolean, onAllow: () -> Unit) {
|
||||
if (on) {
|
||||
AppBadge(stringResource(R.string.reporting_on), tint = JadeiteOk)
|
||||
} else {
|
||||
OutlinedButton(onClick = onAllow) {
|
||||
Text(stringResource(R.string.reporting_allow))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun openUsageAccessSettings(context: Context) {
|
||||
context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||
}
|
||||
|
||||
/**
|
||||
* A fleet row's one-line state: battery, network, current app, and how
|
||||
* long ago any of it landed.
|
||||
*/
|
||||
@Composable
|
||||
private fun fleetRowDescription(phone: PhoneInfo, mine: Boolean): String {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
(phone.topics["battery"]?.field("value"))?.contentOrNull?.let { level ->
|
||||
val charging = phone.topics["battery"]?.field("charging")?.booleanOrNull == true
|
||||
parts += if (charging) "$level% charging" else "$level%"
|
||||
}
|
||||
phone.topics["network"]?.field("value")?.contentOrNull?.let { parts += it }
|
||||
phone.topics["app"]?.field("label")?.contentOrNull?.let { parts += it }
|
||||
?: phone.topics["app"]?.field("value")?.contentOrNull
|
||||
// No label on the payload: the package's last segment is the
|
||||
// closest readable name ("maps" out of com.google.android.maps).
|
||||
?.let { pkg -> pkg.substringAfterLast('.').ifEmpty { pkg } }
|
||||
?.let { parts += it }
|
||||
|
||||
val seen = when {
|
||||
phone.lastSeen <= 0L -> stringResource(R.string.phones_never_reported)
|
||||
System.currentTimeMillis() - phone.lastSeen < 60_000 ->
|
||||
stringResource(R.string.phones_seen_just_now)
|
||||
else -> stringResource(R.string.phones_seen_fmt, relativeTime(phone.lastSeen))
|
||||
}
|
||||
if (mine && parts.isEmpty()) parts += stringResource(R.string.phones_never_reported)
|
||||
|
||||
val summary = parts.joinToString(" · ")
|
||||
return if (summary.isEmpty()) seen else "$summary · $seen"
|
||||
}
|
||||
|
||||
private fun JsonObject.field(name: String): JsonPrimitive? = this[name] as? JsonPrimitive
|
||||
|
||||
/** A UUID is noise; its first block is enough to tell rows apart. */
|
||||
private fun shortDeviceId(device: String): String = device.take(8)
|
||||
|
||||
private fun relativeTime(thenMillis: Long): String {
|
||||
val minutes = ((System.currentTimeMillis() - thenMillis) / 60000L).coerceAtLeast(0)
|
||||
return when {
|
||||
minutes < 60 -> "${minutes}m"
|
||||
minutes < 60 * 24 -> "${minutes / 60}h"
|
||||
else -> "${minutes / (60 * 24)}d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import rs.rhema.phone.R
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import rs.rhema.phone.net.SessionInfo
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The session inventory, the design's sidebar as a screen: grouped
|
||||
// interactive-first rows with an honest state dot and the inbox depth.
|
||||
// Displayed names drop the `interactive:` prefix; the full key is what
|
||||
// gets opened.
|
||||
@Composable
|
||||
fun SessionsScreen(
|
||||
container: AppContainer,
|
||||
onOpenSession: (String) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var sessions by remember { mutableStateOf<List<SessionInfo>?>(null) }
|
||||
var error by remember { mutableStateOf<UiMessage?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
|
||||
val refresh: () -> Unit = {
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
// The agent may have renamed or re-faced itself on another
|
||||
// surface; this button is the manual path for the cached identity.
|
||||
container.identity.refresh()
|
||||
when (val result = container.api.sessions()) {
|
||||
is ClawResult.Ok -> sessions = result.value
|
||||
is ClawResult.HttpError -> error = UiMessage.Http(result.code, result.body)
|
||||
is ClawResult.Network -> error = UiMessage.Network(result.message)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PageHeader(
|
||||
title = stringResource(R.string.sessions_title),
|
||||
actions = {
|
||||
GhostIcon(Icons.Filled.Settings, stringResource(R.string.sessions_open_settings), onClick = onOpenSettings)
|
||||
GhostIcon(Icons.Filled.Refresh, stringResource(R.string.sessions_refresh), enabled = !loading, onClick = refresh)
|
||||
},
|
||||
)
|
||||
|
||||
val currentError = error
|
||||
val visible = sessions?.let(::displayable)
|
||||
when {
|
||||
currentError != null -> Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AppCallout(
|
||||
tone = CalloutTone.Error,
|
||||
text = when (currentError) {
|
||||
is UiMessage.Http -> stringResource(R.string.sessions_http_error, currentError.code) +
|
||||
(currentError.detail?.let { " — $it" } ?: "")
|
||||
is UiMessage.Network -> stringResource(R.string.sessions_network_error, currentError.message)
|
||||
},
|
||||
)
|
||||
if (currentError is UiMessage.Http && currentError.code == 401) {
|
||||
Text(
|
||||
stringResource(R.string.sessions_unauthorized_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onOpenSettings) {
|
||||
Text(stringResource(R.string.sessions_open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
loading && sessions == null -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
visible != null && visible.isEmpty() -> Text(
|
||||
stringResource(R.string.sessions_empty),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
visible != null -> SessionsList(
|
||||
sessions = visible,
|
||||
onOpenSession = onOpenSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SessionGroup(val labelKey: String, val rows: List<SessionInfo>)
|
||||
|
||||
private const val GROUP_INTERACTIVE = "interactive"
|
||||
private const val GROUP_OTHER = "other"
|
||||
|
||||
// `phone:<device>:<topic>` keys are this device's own status reports,
|
||||
// not conversations — listed they read as broken empty sessions, so
|
||||
// they never reach a group.
|
||||
private fun displayable(sessions: List<SessionInfo>): List<SessionInfo> =
|
||||
sessions.filterNot { it.key.startsWith("phone:") }
|
||||
|
||||
// Interactive keys are the chat surface and lead; other dispatch keys
|
||||
// read after. Key order is stable within a group.
|
||||
private fun groupSessions(sessions: List<SessionInfo>): List<SessionGroup> {
|
||||
val byKey = compareBy<SessionInfo> { it.key }
|
||||
val visible = displayable(sessions)
|
||||
val interactive = visible.filter { it.key.startsWith("interactive:") }.sortedWith(byKey)
|
||||
val others = visible.filterNot { it.key.startsWith("interactive:") }.sortedWith(byKey)
|
||||
val groups = mutableListOf<SessionGroup>()
|
||||
if (interactive.isNotEmpty()) {
|
||||
groups += SessionGroup(GROUP_INTERACTIVE, interactive)
|
||||
}
|
||||
if (others.isNotEmpty()) {
|
||||
groups += SessionGroup(GROUP_OTHER, others)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionsList(sessions: List<SessionInfo>, onOpenSession: (String) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
groupSessions(sessions).forEach { group ->
|
||||
item(key = group.labelKey) {
|
||||
SectionLabel(
|
||||
stringResource(
|
||||
if (group.labelKey == GROUP_INTERACTIVE) {
|
||||
R.string.sessions_group_interactive
|
||||
} else {
|
||||
R.string.sessions_group_other
|
||||
},
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(group.rows, key = { it.key }) { session ->
|
||||
SessionRow(session) { onOpenSession(session.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(session: SessionInfo, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
StatusDot(tone = stateTone(session.state))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
session.key.removePrefix("interactive:"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
session.state,
|
||||
session.revision?.let { "rev $it" },
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
val depth = session.inboxDepth
|
||||
if (depth != null && depth > 0) {
|
||||
AppBadge(depth.toString(), tint = JadeiteInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Leased keys are alive, reacquiring ones are troubled, the rest idle. */
|
||||
private fun stateTone(state: String?): DotTone = when (state) {
|
||||
"leased" -> DotTone.Ok
|
||||
"reacquire" -> DotTone.Warn
|
||||
else -> DotTone.Idle
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import rs.rhema.phone.R
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.net.ActivePair
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import rs.rhema.phone.net.ModelCard
|
||||
import rs.rhema.phone.net.ProvidersState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Settings in the design language, as a nav of submenus mirroring the
|
||||
// web UI's settings sidebar: labeled groups ("Phone", "Claw") of one
|
||||
// page each — status reporting, devices, model providers, connection.
|
||||
// First launch is the exception: it lands straight on the connection
|
||||
// page and stays there until GET /healthz succeeds.
|
||||
@Composable
|
||||
fun SettingsScreen(container: AppContainer, onConnected: () -> Unit = {}) {
|
||||
val configured by container.settings.configured.collectAsState(initial = null)
|
||||
var section by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
// Back out of a submenu before leaving settings entirely; the
|
||||
// caller's BackHandler (enabled only when this one is not) takes
|
||||
// the rest.
|
||||
BackHandler(enabled = section != null && configured == true) { section = null }
|
||||
|
||||
when {
|
||||
configured == false -> ConnectionPage(container, onConnected = onConnected, onBack = null)
|
||||
section == null -> SettingsNav { section = it }
|
||||
else -> when (section) {
|
||||
SECTION_REPORTING -> SettingsPage(
|
||||
title = stringResource(R.string.settings_entry_reporting),
|
||||
onBack = { section = null },
|
||||
) { ReportingCard() }
|
||||
SECTION_DEVICES -> SettingsPage(
|
||||
title = stringResource(R.string.settings_entry_devices),
|
||||
onBack = { section = null },
|
||||
) { PhonesCard(container) }
|
||||
SECTION_PROVIDERS -> SettingsPage(
|
||||
title = stringResource(R.string.settings_entry_providers),
|
||||
onBack = { section = null },
|
||||
) { ProvidersCard(container) }
|
||||
else -> ConnectionPage(container, onConnected = onConnected, onBack = { section = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The nav entries. Ids are stable spellings (saveable state survives
|
||||
// rotation through them).
|
||||
private const val SECTION_REPORTING = "reporting"
|
||||
private const val SECTION_DEVICES = "devices"
|
||||
private const val SECTION_PROVIDERS = "providers"
|
||||
private const val SECTION_CONNECTION = "connection"
|
||||
|
||||
private data class SettingsEntry(val id: String, val labelRes: Int, val descRes: Int)
|
||||
private data class SettingsSection(val labelRes: Int, val entries: List<SettingsEntry>)
|
||||
|
||||
private val SECTIONS = listOf(
|
||||
SettingsSection(
|
||||
R.string.settings_group_phone,
|
||||
listOf(
|
||||
SettingsEntry(
|
||||
SECTION_REPORTING,
|
||||
R.string.settings_entry_reporting,
|
||||
R.string.settings_entry_reporting_desc,
|
||||
),
|
||||
SettingsEntry(
|
||||
SECTION_DEVICES,
|
||||
R.string.settings_entry_devices,
|
||||
R.string.settings_entry_devices_desc,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsSection(
|
||||
R.string.settings_group_claw,
|
||||
listOf(
|
||||
SettingsEntry(
|
||||
SECTION_PROVIDERS,
|
||||
R.string.settings_entry_providers,
|
||||
R.string.settings_entry_providers_desc,
|
||||
),
|
||||
SettingsEntry(
|
||||
SECTION_CONNECTION,
|
||||
R.string.settings_entry_connection,
|
||||
R.string.settings_entry_connection_desc,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SettingsNav(onSelect: (String) -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
PageHeader(title = stringResource(R.string.settings_page_title))
|
||||
SECTIONS.forEach { section ->
|
||||
SectionLabel(
|
||||
stringResource(section.labelRes),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
section.entries.forEach { entry ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(entry.id) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(entry.labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
stringResource(entry.descRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
Text("›", style = MaterialTheme.typography.titleMedium, color = JadeiteText3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A submenu: a back arrow and title over the page's card. */
|
||||
@Composable
|
||||
private fun SettingsPage(title: String, onBack: () -> Unit, content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
tint = JadeiteText2,
|
||||
)
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The connection page: first launch lands here alone (onBack = null
|
||||
* and the header is the plain Settings title); from the nav it is a
|
||||
* submenu like the rest. */
|
||||
@Composable
|
||||
private fun ConnectionPage(container: AppContainer, onConnected: () -> Unit, onBack: (() -> Unit)?) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var baseUrl by rememberSaveable { mutableStateOf("") }
|
||||
var token by rememberSaveable { mutableStateOf("") }
|
||||
var busy by rememberSaveable { mutableStateOf(false) }
|
||||
var status by remember { mutableStateOf<UiMessage?>(null) }
|
||||
val deviceId by container.settings.deviceIdFlow.collectAsState(initial = "…")
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val saved = container.settings.current()
|
||||
if (saved.baseUrl.isNotBlank()) baseUrl = saved.baseUrl
|
||||
if (saved.token.isNotBlank()) token = saved.token
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
if (onBack == null) {
|
||||
PageHeader(title = stringResource(R.string.settings_page_title))
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
tint = JadeiteText2,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.settings_entry_connection),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
ConnectionCard(
|
||||
baseUrl = baseUrl,
|
||||
onBaseUrl = { baseUrl = it },
|
||||
token = token,
|
||||
onToken = { token = it },
|
||||
busy = busy,
|
||||
onConnect = {
|
||||
busy = true
|
||||
status = null
|
||||
scope.launch {
|
||||
container.settings.setCredentials(baseUrl.trim(), token.trim())
|
||||
when (val result = container.api.health()) {
|
||||
is ClawResult.Ok -> onConnected()
|
||||
is ClawResult.HttpError -> status = UiMessage.Http(result.code, result.body)
|
||||
is ClawResult.Network -> status = UiMessage.Network(result.message)
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
status = status,
|
||||
deviceId = deviceId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionCard(
|
||||
baseUrl: String,
|
||||
onBaseUrl: (String) -> Unit,
|
||||
token: String,
|
||||
onToken: (String) -> Unit,
|
||||
busy: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
status: UiMessage?,
|
||||
deviceId: String,
|
||||
) {
|
||||
RhemaCard(title = stringResource(R.string.card_connection)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = onBaseUrl,
|
||||
label = { Text(stringResource(R.string.field_base_url)) },
|
||||
singleLine = true,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = sunkenFieldColors(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = onToken,
|
||||
label = { Text(stringResource(R.string.field_token)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = sunkenFieldColors(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(enabled = !busy && baseUrl.isNotBlank(), onClick = onConnect) {
|
||||
Text(stringResource(R.string.save_and_connect))
|
||||
}
|
||||
}
|
||||
val currentStatus = status
|
||||
if (currentStatus != null) {
|
||||
val text: String = when (currentStatus) {
|
||||
is UiMessage.Http -> stringResource(R.string.settings_health_http, currentStatus.code) +
|
||||
(currentErrorDetail(currentStatus) ?: "")
|
||||
is UiMessage.Network -> stringResource(R.string.settings_health_network, currentStatus.message)
|
||||
}
|
||||
AppCallout(tone = CalloutTone.Error, text = text)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.device_id_fmt, deviceId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = JadeiteMono,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.settings_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentErrorDetail(status: UiMessage.Http): String? =
|
||||
status.detail?.let { " — $it" }
|
||||
|
||||
/** The Model providers card: the staged pair, its listing, and Save/Discard. */
|
||||
@Composable
|
||||
private fun ProvidersCard(container: AppContainer) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<ProvidersState?>(null) }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
var listing by remember { mutableStateOf<ListingState>(ListingState.Idle) }
|
||||
var draftProvider by remember { mutableStateOf<String?>(null) }
|
||||
var draftModel by remember { mutableStateOf(DEFAULT_MODEL) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var savedPair by remember { mutableStateOf<ActivePair?>(null) }
|
||||
var saveError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun defaultOf(id: String?): String =
|
||||
state?.providers?.firstOrNull { it.id == id }?.defaultModel ?: ""
|
||||
|
||||
fun loadModels(id: String) {
|
||||
listing = ListingState.Loading
|
||||
scope.launch {
|
||||
when (val result = container.api.providerModels(id)) {
|
||||
is ClawResult.Ok -> listing = ListingState.Available(result.value.models)
|
||||
is ClawResult.HttpError ->
|
||||
// A 404 is the documented "no live listing", not a
|
||||
// failure: the instance still switches on its default model.
|
||||
listing = ListingState.Unavailable("HTTP ${result.code}")
|
||||
is ClawResult.Network -> listing = ListingState.Unavailable(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
when (val result = container.api.providers()) {
|
||||
is ClawResult.Ok -> {
|
||||
state = result.value
|
||||
val active = result.value.active
|
||||
draftProvider = active.provider
|
||||
draftModel = if (active.model == defaultOf(active.provider)) DEFAULT_MODEL else active.model
|
||||
loadModels(active.provider)
|
||||
}
|
||||
is ClawResult.HttpError -> loadError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||
is ClawResult.Network -> loadError = result.message
|
||||
}
|
||||
}
|
||||
|
||||
val active = state?.active
|
||||
val dirty = active != null && draftProvider != null && (
|
||||
draftProvider != active.provider ||
|
||||
(draftModel.ifBlank { defaultOf(draftProvider) }) != active.model
|
||||
)
|
||||
|
||||
RhemaCard(
|
||||
title = stringResource(R.string.card_providers),
|
||||
meta = stringResource(R.string.providers_hint),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val currentLoadError = loadError
|
||||
val currentState = state
|
||||
when {
|
||||
currentLoadError != null -> AppCallout(
|
||||
tone = CalloutTone.Error,
|
||||
text = stringResource(R.string.providers_error, currentLoadError),
|
||||
)
|
||||
currentState == null -> Text(
|
||||
stringResource(R.string.providers_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
stringResource(R.string.providers_field_provider),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
RhemaSelect(
|
||||
selected = draftProvider,
|
||||
options = currentState.providers.map { it.id to it.id },
|
||||
enabled = !saving,
|
||||
onSelect = { id ->
|
||||
draftProvider = id
|
||||
draftModel = DEFAULT_MODEL
|
||||
loadModels(id)
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.providers_field_model),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
RhemaSelect(
|
||||
selected = draftModel,
|
||||
options = when (val l = listing) {
|
||||
is ListingState.Available ->
|
||||
listOf(DEFAULT_MODEL to stringResource(R.string.providers_default_option)) +
|
||||
l.models.map { it.id to it.id }
|
||||
else -> listOf(DEFAULT_MODEL to stringResource(R.string.providers_default_option))
|
||||
},
|
||||
enabled = !saving,
|
||||
onSelect = { draftModel = it },
|
||||
)
|
||||
if (listing is ListingState.Unavailable) {
|
||||
Text(
|
||||
stringResource(R.string.providers_none),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = JadeiteText3,
|
||||
)
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
AppCallout(
|
||||
tone = CalloutTone.Warn,
|
||||
title = stringResource(R.string.providers_staged),
|
||||
text = stringResource(R.string.providers_staged_detail),
|
||||
)
|
||||
}
|
||||
savedPair?.let { pair ->
|
||||
AppCallout(
|
||||
tone = CalloutTone.Ok,
|
||||
text = stringResource(R.string.providers_saved, pair.provider, pair.model),
|
||||
)
|
||||
}
|
||||
saveError?.let { err ->
|
||||
AppCallout(
|
||||
tone = CalloutTone.Error,
|
||||
text = stringResource(R.string.providers_error, err),
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
enabled = dirty && !saving,
|
||||
onClick = {
|
||||
val activeNow = state?.active ?: return@OutlinedButton
|
||||
draftProvider = activeNow.provider
|
||||
draftModel =
|
||||
if (activeNow.model == defaultOf(activeNow.provider)) DEFAULT_MODEL else activeNow.model
|
||||
savedPair = null
|
||||
saveError = null
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.providers_discard))
|
||||
}
|
||||
Button(
|
||||
enabled = dirty && !saving && draftProvider != null,
|
||||
onClick = {
|
||||
val provider = draftProvider ?: return@Button
|
||||
saving = true
|
||||
savedPair = null
|
||||
saveError = null
|
||||
scope.launch {
|
||||
when (
|
||||
val result = container.api.setActiveProvider(
|
||||
provider,
|
||||
draftModel.ifBlank { null },
|
||||
)
|
||||
) {
|
||||
is ClawResult.Ok -> {
|
||||
// The route answers the new pair only;
|
||||
// carry the provider list forward.
|
||||
val current = state
|
||||
state = if (current != null) {
|
||||
current.copy(active = result.value.active)
|
||||
} else {
|
||||
ProvidersState(result.value.active, emptyList())
|
||||
}
|
||||
savedPair = result.value.active
|
||||
draftProvider = result.value.active.provider
|
||||
draftModel = if (
|
||||
result.value.active.model == defaultOf(result.value.active.provider)
|
||||
) {
|
||||
DEFAULT_MODEL
|
||||
} else {
|
||||
result.value.active.model
|
||||
}
|
||||
}
|
||||
is ClawResult.HttpError ->
|
||||
saveError = "HTTP ${result.code}" + (result.body?.let { " — $it" } ?: "")
|
||||
is ClawResult.Network -> saveError = result.message
|
||||
}
|
||||
saving = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.providers_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ListingState {
|
||||
data object Idle : ListingState
|
||||
data object Loading : ListingState
|
||||
data class Available(val models: List<ModelCard>) : ListingState
|
||||
data class Unavailable(val reason: String) : ListingState
|
||||
}
|
||||
|
||||
private const val DEFAULT_MODEL = ""
|
||||
|
||||
/** The design's input voice: sunken ground, hairline border, accent focus. */
|
||||
@Composable
|
||||
private fun sunkenFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = JadeiteAccent,
|
||||
unfocusedBorderColor = JadeiteBorder,
|
||||
focusedContainerColor = JadeiteSunken,
|
||||
unfocusedContainerColor = JadeiteSunken,
|
||||
cursorColor = JadeiteAccentText,
|
||||
focusedTextColor = JadeiteText,
|
||||
unfocusedTextColor = JadeiteText,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The Jadeite dark tokens, lifted from crates/rhema-code/src/palette.rs
|
||||
// DARK: one design system across the TUI, the web UI, and this app.
|
||||
// Dark is the design's primary; there is deliberately no light scheme
|
||||
// and no dynamic color — the palette is the brand.
|
||||
/** The design's dark ground; also the system-bar scrim. */
|
||||
internal val JadeiteGround = Color(0xFF0F1413)
|
||||
internal val JadeiteSurface = Color(0xFF171D1B)
|
||||
internal val JadeiteElevated = Color(0xFF1F2624)
|
||||
internal val JadeiteSunken = Color(0xFF0A0F0E)
|
||||
internal val JadeiteBorder = Color(0xFF28312E)
|
||||
internal val JadeiteBorderStrong = Color(0xFF3D4844)
|
||||
internal val JadeiteText = Color(0xFFEEF3F1)
|
||||
internal val JadeiteText2 = Color(0xFFB9C6C1)
|
||||
internal val JadeiteText3 = Color(0xFF87948F)
|
||||
internal val JadeiteAccent = Color(0xFF35A882)
|
||||
internal val JadeiteAccentText = Color(0xFF64C9A3)
|
||||
internal val JadeiteOnAccent = Color(0xFF07110D)
|
||||
internal val JadeiteOk = Color(0xFF4FBF94)
|
||||
internal val JadeiteWarn = Color(0xFFD9A643)
|
||||
internal val JadeiteError = Color(0xFFE4687A)
|
||||
internal val JadeiteInfo = Color(0xFF8FA6B8)
|
||||
|
||||
/** Machine spellings — keys, model ids, outcomes — render mono. */
|
||||
internal val JadeiteMono: FontFamily = FontFamily.Monospace
|
||||
|
||||
private val JadeiteDark = darkColorScheme(
|
||||
primary = JadeiteAccent,
|
||||
onPrimary = JadeiteOnAccent,
|
||||
primaryContainer = JadeiteElevated,
|
||||
onPrimaryContainer = JadeiteAccentText,
|
||||
secondary = JadeiteAccentText,
|
||||
onSecondary = JadeiteOnAccent,
|
||||
tertiary = JadeiteInfo,
|
||||
background = JadeiteGround,
|
||||
onBackground = JadeiteText,
|
||||
surface = JadeiteSurface,
|
||||
onSurface = JadeiteText,
|
||||
surfaceVariant = JadeiteElevated,
|
||||
onSurfaceVariant = JadeiteText2,
|
||||
surfaceContainerLowest = JadeiteGround,
|
||||
surfaceContainerLow = JadeiteSurface,
|
||||
surfaceContainer = JadeiteSurface,
|
||||
surfaceContainerHigh = JadeiteElevated,
|
||||
surfaceContainerHighest = JadeiteElevated,
|
||||
outline = JadeiteBorder,
|
||||
outlineVariant = JadeiteBorderStrong,
|
||||
error = JadeiteError,
|
||||
)
|
||||
|
||||
// The design's shape language: soft cards on a hairline grid, controls a
|
||||
// step softer. Default Material radii read as someone else's app.
|
||||
private val JadeiteShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(10.dp),
|
||||
large = RoundedCornerShape(14.dp),
|
||||
)
|
||||
|
||||
/** The app's theme: Jadeite dark, always — matching the TUI and the web UI. */
|
||||
@Composable
|
||||
fun RhemaTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = JadeiteDark,
|
||||
shapes = JadeiteShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package rs.rhema.phone.ui
|
||||
|
||||
// Screen state holds the failure data, not a formatted string; user-visible
|
||||
// text is resolved with stringResource at render time so configuration
|
||||
// changes re-resolve it.
|
||||
sealed interface UiMessage {
|
||||
data class Http(val code: Int, val detail: String? = null) : UiMessage
|
||||
data class Network(val message: String) : UiMessage
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package rs.rhema.phone.work
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
// WorkManager already re-enqueues its own periodic work after reboot; this
|
||||
// receiver is the explicit boot kick. It is idempotent (KEEP policy).
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
schedulePeriodicWork(context.applicationContext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package rs.rhema.phone.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import rs.rhema.phone.RhemaApp
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
// Periodic notification poll. Latency is bounded by the WorkManager interval
|
||||
// (15 minutes, the OS floor); without push infrastructure that is the deal.
|
||||
// The chat screen is always live via SSE regardless.
|
||||
class NotificationsWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val container = (applicationContext as RhemaApp).container
|
||||
val deviceId = container.settings.deviceId()
|
||||
val since = container.status.notificationCursor.first()
|
||||
|
||||
val notifications = when (val result = container.api.notifications(deviceId, since)) {
|
||||
is ClawResult.Ok -> result.value
|
||||
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
|
||||
is ClawResult.Network -> return Result.retry()
|
||||
}
|
||||
|
||||
for (notification in notifications) {
|
||||
container.notifier.post(
|
||||
notification.id.hashCode(),
|
||||
notification.title ?: container.notifier.fallbackTitle(),
|
||||
notification.body.orEmpty(),
|
||||
)
|
||||
// Acks are best-effort: the cursor means "seen", not "acked", so a
|
||||
// failed ack does not re-notify on the next poll.
|
||||
container.api.ackNotification(notification.id.toString())
|
||||
container.status.setNotificationCursor(notification.id.toString())
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package rs.rhema.phone.work
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import rs.rhema.phone.RhemaApp
|
||||
import rs.rhema.phone.di.AppContainer
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import rs.rhema.phone.net.PhoneCommand
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
// Periodic control-plane poll: the claw queues commands for this device
|
||||
// once their phone consent is granted; each is executed in order, then
|
||||
// answered. The result POST is the ack, so a failed post leaves the
|
||||
// command queued and the next run repeats it — at-least-once delivery.
|
||||
class PhoneCommandWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val container = (applicationContext as RhemaApp).container
|
||||
val deviceId = container.settings.deviceId()
|
||||
|
||||
val commands = when (val result = container.api.phoneCommands(deviceId)) {
|
||||
is ClawResult.Ok -> result.value
|
||||
is ClawResult.HttpError -> return if (result.code >= 500) Result.retry() else Result.failure()
|
||||
is ClawResult.Network -> return Result.retry()
|
||||
}
|
||||
|
||||
for (command in commands) {
|
||||
val (ok, detail) = execute(container, command)
|
||||
// Stop at the first failed ack: the queue is ordered, later
|
||||
// commands must not jump ahead of an un-acked one.
|
||||
if (container.api.postPhoneCommandResult(command.id, ok, detail) !is ClawResult.Ok) {
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// One command, locally. The answer pair rides to the claw verbatim;
|
||||
// the detail strings are wire content, not UI resources.
|
||||
private fun execute(container: AppContainer, command: PhoneCommand): Pair<Boolean, String?> {
|
||||
val notifications = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
return when (command.action) {
|
||||
"dnd_on", "dnd_off" -> {
|
||||
// Policy access is a settings grant, not a permission: it
|
||||
// can only be given by the user in system settings, so a
|
||||
// missing grant is reported back, never prompted here.
|
||||
if (notifications?.isNotificationPolicyAccessGranted != true) {
|
||||
false to DND_ACCESS_DETAIL
|
||||
} else {
|
||||
notifications.setInterruptionFilter(
|
||||
if (command.action == "dnd_on") {
|
||||
NotificationManager.INTERRUPTION_FILTER_PRIORITY
|
||||
} else {
|
||||
NotificationManager.INTERRUPTION_FILTER_ALL
|
||||
},
|
||||
)
|
||||
true to null
|
||||
}
|
||||
}
|
||||
"alert" -> {
|
||||
val posted = container.notifier.postAlert(
|
||||
command.id.hashCode(),
|
||||
command.args.text("title") ?: "claw",
|
||||
command.args.text("body") ?: command.action,
|
||||
)
|
||||
if (posted) {
|
||||
true to null
|
||||
} else {
|
||||
true to "notification permission not granted"
|
||||
}
|
||||
}
|
||||
else -> false to "unknown action"
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DND_ACCESS_DETAIL =
|
||||
"grant Do Not Disturb access to Rhema (Settings → Special app access)"
|
||||
}
|
||||
}
|
||||
|
||||
// Only primitive strings count; anything else falls back to the
|
||||
// caller's default.
|
||||
private fun JsonObject.text(name: String): String? =
|
||||
(this[name] as? JsonPrimitive)?.contentOrNull
|
||||
@@ -0,0 +1,227 @@
|
||||
package rs.rhema.phone.work
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import rs.rhema.phone.RhemaApp
|
||||
import rs.rhema.phone.data.PhoneSnapshot
|
||||
import rs.rhema.phone.net.ClawResult
|
||||
import rs.rhema.phone.sensors.LocationFix
|
||||
import rs.rhema.phone.sensors.ForegroundApp
|
||||
import rs.rhema.phone.sensors.PhoneSensors
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// Periodic phone-status source. Reads one snapshot, diffs it against the last
|
||||
// one that was fully POSTed, and ingests only the transitions. If any post
|
||||
// fails the snapshot is not saved, so the next run retries the transitions.
|
||||
//
|
||||
// Two kinds of topic leave this worker. Battery, network, and DND posts are
|
||||
// conversation: the claw dispatches them and the agent hears the flip. The
|
||||
// rest — location, app, device, wifi, alarm, heartbeat — are snapshot-only on
|
||||
// the claw
|
||||
// (its ingest door answers "observed" and never runs a turn), so their rules
|
||||
// only bound write traffic, not model traffic.
|
||||
class PhoneStatusWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val container = (applicationContext as RhemaApp).container
|
||||
val deviceId = container.settings.deviceId()
|
||||
// Suspend: the location read may wait out a fresh fix.
|
||||
val reading = readSnapshot(applicationContext)
|
||||
val snapshot = reading.sensors
|
||||
val last = container.status.lastSnapshot()
|
||||
|
||||
val transitions = mutableListOf<Pair<String, JsonObject>>()
|
||||
|
||||
// Battery posts on charging flips and decile bucket changes only, so
|
||||
// slow drain does not ping on every run.
|
||||
val charging = snapshot.charging
|
||||
val level = snapshot.batteryLevel
|
||||
val batteryChanged = (charging != null && charging != last?.charging) ||
|
||||
(level != null && bucket(level) != last?.batteryLevel?.let { bucket(it) })
|
||||
if (batteryChanged) {
|
||||
transitions += "battery" to buildJsonObject {
|
||||
put("topic", "battery")
|
||||
put("value", level?.toString() ?: "unknown")
|
||||
put("charging", charging)
|
||||
}
|
||||
}
|
||||
|
||||
val network = snapshot.network
|
||||
if (network != null && network != last?.network) {
|
||||
transitions += "network" to buildJsonObject {
|
||||
put("topic", "network")
|
||||
put("value", network)
|
||||
}
|
||||
}
|
||||
|
||||
val dnd = snapshot.dnd
|
||||
if (dnd != null && dnd != last?.dnd) {
|
||||
transitions += "dnd" to buildJsonObject {
|
||||
put("topic", "dnd")
|
||||
put("value", dnd)
|
||||
}
|
||||
}
|
||||
|
||||
// Location posts when the phone moved a meaningful distance since the
|
||||
// last posted fix (or never posted one). Stationary drift stays under
|
||||
// the threshold; a commute clears it per stop. Snapshot-only: the fix
|
||||
// lands in the claw's tables, no turn is run on it.
|
||||
val fix = reading.fix
|
||||
val metresMoved = fix?.let { PhoneSensors.metresBetween(last?.locationLat, last?.locationLon, it) }
|
||||
if (fix != null && (metresMoved == null || metresMoved >= LOCATION_MOVE_M)) {
|
||||
transitions += "location" to buildJsonObject {
|
||||
put("topic", "location")
|
||||
put("lat", fix.lat)
|
||||
put("lon", fix.lon)
|
||||
put("accuracy_m", fix.accuracyM)
|
||||
put("provider", fix.provider)
|
||||
put("age_s", fix.ageS)
|
||||
}
|
||||
}
|
||||
|
||||
// The foreground app posts on change; the age says how long it has
|
||||
// been current, which is what keeps "currently open" honest at a
|
||||
// 15-minute cadence. Snapshot-only.
|
||||
val app = snapshot.app
|
||||
if (app != null && app != last?.app) {
|
||||
transitions += "app" to buildJsonObject {
|
||||
put("topic", "app")
|
||||
put("value", app)
|
||||
put("label", reading.foreground?.label)
|
||||
put("age_s", reading.foreground?.ageS ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// The device identity posts once, and again whenever it changes (an
|
||||
// OS upgrade, an app update): the claw keeps it as the fleet label,
|
||||
// so this topic is what makes the settings screens human-readable.
|
||||
// Snapshot-only.
|
||||
if (snapshot.device != null && snapshot.device != last?.device) {
|
||||
transitions += "device" to buildJsonObject {
|
||||
put("topic", "device")
|
||||
put("value", PhoneSensors.deviceName())
|
||||
put("manufacturer", Build.MANUFACTURER)
|
||||
put("model", Build.MODEL)
|
||||
put("android", Build.VERSION.RELEASE)
|
||||
put("sdk", Build.VERSION.SDK_INT)
|
||||
put("app_version", PhoneSensors.appVersion(applicationContext))
|
||||
}
|
||||
}
|
||||
|
||||
val wifi = snapshot.wifi
|
||||
if (wifi != null && wifi != last?.wifi) {
|
||||
transitions += "wifi" to buildJsonObject {
|
||||
put("topic", "wifi")
|
||||
put("value", wifi)
|
||||
}
|
||||
}
|
||||
|
||||
val alarm = snapshot.alarm
|
||||
if (alarm != null && alarm != last?.alarm) {
|
||||
transitions += "alarm" to buildJsonObject {
|
||||
put("topic", "alarm")
|
||||
put("value", alarm)
|
||||
put("in_min", (alarm - System.currentTimeMillis()) / 60000)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changed is not nothing to say: the claw's fleet
|
||||
// registry stamps "last seen" on whatever post lands, so a run
|
||||
// with no transitions posts a bare heartbeat to keep its row
|
||||
// fresh at the worker's cadence instead of the last flip. The
|
||||
// topic is snapshot-only claw-side; no turn runs on it.
|
||||
if (transitions.isEmpty()) {
|
||||
transitions += "heartbeat" to buildJsonObject {
|
||||
put("topic", "heartbeat")
|
||||
put("ts", System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
for ((topic, payload) in transitions) {
|
||||
val result = container.api.ingest(SOURCE, "phone:$deviceId:$topic", payload)
|
||||
if (result !is ClawResult.Ok) return Result.retry()
|
||||
}
|
||||
container.status.setSnapshot(snapshot)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SOURCE = "phone"
|
||||
|
||||
// A fix counts as a move at this distance; below it, the phone has
|
||||
// not gone anywhere worth a post.
|
||||
const val LOCATION_MOVE_M = 300f
|
||||
|
||||
fun bucket(level: Int): Int = level / 10
|
||||
|
||||
private suspend fun readSnapshot(context: Context): Reading {
|
||||
val battery = context.getSystemService(BatteryManager::class.java)
|
||||
val level = battery
|
||||
?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
?.takeIf { it in 1..100 }
|
||||
|
||||
// Peek the sticky battery intent (null receiver, so no export flag
|
||||
// applies). This is the only reliable charging signal without
|
||||
// listening to the chatty ACTION_BATTERY_CHANGED stream.
|
||||
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val charging = sticky?.let { intent ->
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL ||
|
||||
intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|
||||
}
|
||||
|
||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
val network = when {
|
||||
connectivity == null -> null
|
||||
connectivity.activeNetwork == null -> "offline"
|
||||
connectivity.isActiveNetworkMetered -> "metered"
|
||||
else -> "wifi"
|
||||
}
|
||||
|
||||
val notifications = context.getSystemService(NotificationManager::class.java)
|
||||
val dnd = notifications?.currentInterruptionFilter
|
||||
?.takeIf { it != NotificationManager.INTERRUPTION_FILTER_UNKNOWN }
|
||||
?.let { if (it == NotificationManager.INTERRUPTION_FILTER_ALL) "off" else "on" }
|
||||
|
||||
// A fresh fix beats the last-known cache: on a stationary
|
||||
// phone the cache is minutes-to-hours old and reports where
|
||||
// the phone was, not where it is.
|
||||
val fix = PhoneSensors.currentFix(context)
|
||||
val foreground = PhoneSensors.foregroundApp(context)
|
||||
val sensors = PhoneSnapshot(
|
||||
charging = charging,
|
||||
batteryLevel = level,
|
||||
network = network,
|
||||
dnd = dnd,
|
||||
locationLat = fix?.lat,
|
||||
locationLon = fix?.lon,
|
||||
app = foreground?.pkg,
|
||||
device = PhoneSensors.deviceFingerprint(context),
|
||||
wifi = PhoneSensors.wifiSsid(context),
|
||||
alarm = PhoneSensors.nextAlarm(context),
|
||||
)
|
||||
return Reading(sensors, fix, foreground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One read of every sensor: the diffable [PhoneSnapshot] the run
|
||||
* persists on success, plus the rich readings whose payload fields (a
|
||||
* fix's accuracy, an app's label) never need persisting.
|
||||
*/
|
||||
private data class Reading(
|
||||
val sensors: PhoneSnapshot,
|
||||
val fix: LocationFix?,
|
||||
val foreground: ForegroundApp?,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package rs.rhema.phone.work
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.BatteryManager
|
||||
import rs.rhema.phone.RhemaApp
|
||||
|
||||
// Registered dynamically for the process lifetime (see RhemaApp): the OS
|
||||
// refuses manifest registration for these implicit broadcasts. Dumb on
|
||||
// purpose — it only enqueues work; the worker does all reading and posting.
|
||||
class PowerEventReceiver : BroadcastReceiver() {
|
||||
|
||||
// In-memory only: coalesces ACTION_BATTERY_CHANGED (which fires on every
|
||||
// percent point) down to charging flips seen by this process. Process
|
||||
// death simply resets the baseline; the periodic worker covers the gap.
|
||||
private var lastCharging: Boolean? = null
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val app = context.applicationContext as? RhemaApp ?: return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_POWER_CONNECTED,
|
||||
Intent.ACTION_POWER_DISCONNECTED,
|
||||
-> enqueuePhoneStatusNow(app)
|
||||
|
||||
Intent.ACTION_BATTERY_CHANGED -> {
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL
|
||||
if (lastCharging != null && charging != lastCharging) {
|
||||
enqueuePhoneStatusNow(app)
|
||||
}
|
||||
lastCharging = charging
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package rs.rhema.phone.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.time.Duration
|
||||
|
||||
private const val PHONE_STATUS = "phone_status"
|
||||
private const val NOTIFICATIONS = "notifications"
|
||||
private const val PHONE_COMMANDS = "phone_commands"
|
||||
private const val PHONE_STATUS_KICK = "phone_status_kick"
|
||||
private const val PHONE_COMMANDS_KICK = "phone_commands_kick"
|
||||
|
||||
private fun networked() = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
// KEEP everywhere: re-running the schedule (app start, boot) must not reset
|
||||
// the 15-minute cadence. 15 minutes is the WorkManager periodic floor.
|
||||
fun schedulePeriodicWork(context: Context) {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
wm.enqueueUniquePeriodicWork(
|
||||
PHONE_STATUS,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<PhoneStatusWorker>(Duration.ofMinutes(15))
|
||||
.setConstraints(networked())
|
||||
.build(),
|
||||
)
|
||||
wm.enqueueUniquePeriodicWork(
|
||||
NOTIFICATIONS,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<NotificationsWorker>(Duration.ofMinutes(15))
|
||||
.setConstraints(networked())
|
||||
.build(),
|
||||
)
|
||||
wm.enqueueUniquePeriodicWork(
|
||||
PHONE_COMMANDS,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<PhoneCommandWorker>(Duration.ofMinutes(15))
|
||||
.setConstraints(networked())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
// Receiver-side kick for charging flips; KEEP coalesces bursts into one run.
|
||||
fun enqueuePhoneStatusNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
PHONE_STATUS_KICK,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<PhoneStatusWorker>()
|
||||
.setConstraints(networked())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
// Foreground kick for the control queue: the app opening is when the human
|
||||
// is around to see an alert or answer a question. KEEP coalesces resume
|
||||
// bursts into one run.
|
||||
fun enqueuePhoneCommandsNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
PHONE_COMMANDS_KICK,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<PhoneCommandWorker>()
|
||||
.setConstraints(networked())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Single glyph used for both the launcher icon and the notification small
|
||||
icon. The solid silhouette is intentional: notification small icons render
|
||||
alpha only. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF2E7D6F"
|
||||
android:pathData="M4,2h16c1.1,0 2,0.9 2,2v12c0,1.1 -0.9,2 -2,2H8l-4,4V4c0,-1.1 0.9,-2 2,-2zM7,7h10v2H7zM7,11h7v2H7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Rhema</string>
|
||||
|
||||
<string name="channel_name">Agent notifications</string>
|
||||
<string name="alert_channel_name">Agent alerts</string>
|
||||
<string name="notif_default_title">Rhema</string>
|
||||
|
||||
<string name="settings_title">Connect to your claw</string>
|
||||
<string name="field_base_url">Base URL (e.g. http://192.168.1.10:8080)</string>
|
||||
<string name="field_token">Bearer token</string>
|
||||
<string name="save_and_connect">Save and connect</string>
|
||||
<string name="device_id_fmt">Device ID: %1$s</string>
|
||||
<string name="settings_health_http">Saved. The claw answered /healthz with HTTP %1$d; check the URL and token.</string>
|
||||
<string name="settings_health_network">Saved, but /healthz is unreachable: %1$s</string>
|
||||
<string name="settings_hint">The token is stored in this app\'s private storage. The device ID namespaces this phone\'s status events on the claw.</string>
|
||||
|
||||
<string name="sessions_title">Sessions</string>
|
||||
<string name="sessions_empty">No sessions yet.</string>
|
||||
<string name="sessions_refresh">Refresh</string>
|
||||
<string name="sessions_open_settings">Settings</string>
|
||||
<string name="sessions_http_error">The claw returned HTTP %1$d</string>
|
||||
<string name="sessions_network_error">Cannot reach the claw: %1$s</string>
|
||||
<string name="sessions_unauthorized_hint">HTTP 401 means the bearer token is wrong or missing. Update it in Settings.</string>
|
||||
|
||||
<string name="chat_back">Back</string>
|
||||
<string name="chat_send">Send</string>
|
||||
<string name="chat_message_hint">Message the agent</string>
|
||||
<string name="chat_status_connecting">connecting…</string>
|
||||
<string name="chat_status_live">live</string>
|
||||
<string name="chat_status_reconnecting">reconnecting…</string>
|
||||
<string name="chat_status_failed">disconnected: %1$s</string>
|
||||
<string name="chat_status_unauthorized">unauthorized; check the token in Settings</string>
|
||||
<string name="chat_send_failed">Send failed: %1$s</string>
|
||||
<string name="chat_tool_running">tool running: %1$s</string>
|
||||
<string name="chat_tool_done">tool: %1$s</string>
|
||||
<string name="chat_attachment_download">Download and open</string>
|
||||
<string name="chat_attachment_downloading">downloading…</string>
|
||||
<string name="chat_attachment_open">Open</string>
|
||||
<string name="chat_attachment_open_failed">No app on this phone opens this file type.</string>
|
||||
<string name="chat_attachment_failed">Download failed: %1$s</string>
|
||||
<string name="consent_secret_hint">The secret the agent asked for</string>
|
||||
<string name="consent_submit">Send secret</string>
|
||||
<string name="consent_approve">Approve</string>
|
||||
<string name="consent_deny">Deny</string>
|
||||
<string name="consent_granted">granted</string>
|
||||
<string name="consent_denied">denied</string>
|
||||
<string name="consent_answer_failed">Answer failed: %1$s</string>
|
||||
<string name="settings_page_title">Settings</string>
|
||||
<string name="settings_back">Back</string>
|
||||
<string name="settings_group_phone">Phone</string>
|
||||
<string name="settings_group_claw">Claw</string>
|
||||
<string name="settings_entry_reporting">Status reporting</string>
|
||||
<string name="settings_entry_reporting_desc">Location, battery, DND, and the other sensors this phone sends</string>
|
||||
<string name="settings_entry_devices">Devices</string>
|
||||
<string name="settings_entry_devices_desc">Every phone the claw has heard from; forget the stale ones</string>
|
||||
<string name="settings_entry_providers">Model providers</string>
|
||||
<string name="settings_entry_providers_desc">Which backend and model answer the claw\'s turns</string>
|
||||
<string name="settings_entry_connection">Connection</string>
|
||||
<string name="settings_entry_connection_desc">The claw\'s URL and bearer token</string>
|
||||
<string name="card_connection">Connection</string>
|
||||
<string name="card_providers">Model providers</string>
|
||||
<string name="providers_hint">What the claw answers with</string>
|
||||
<string name="providers_field_provider">Provider</string>
|
||||
<string name="providers_field_model">Model</string>
|
||||
<string name="providers_default_option">(provider default)</string>
|
||||
<string name="providers_none">No live listing; the provider\'s default model applies.</string>
|
||||
<string name="providers_saved">Saved: %1$s / %2$s</string>
|
||||
<string name="providers_loading">Loading providers…</string>
|
||||
<string name="providers_staged">Change not applied</string>
|
||||
<string name="providers_staged_detail">The staged pair takes effect on the claw\'s next turn. Save to apply it, discard to keep the running backend.</string>
|
||||
<string name="providers_discard">Discard</string>
|
||||
<string name="providers_save">Save</string>
|
||||
<string name="providers_error">The claw answered: %1$s</string>
|
||||
|
||||
<string name="card_phones">Phones</string>
|
||||
<string name="phones_hint">Every device the claw has heard from</string>
|
||||
<string name="phones_loading">Loading phones…</string>
|
||||
<string name="phones_empty">No phones have reported yet.</string>
|
||||
<string name="phones_this_device">this phone</string>
|
||||
<string name="phones_forget">Forget</string>
|
||||
<string name="phones_forget_title">Forget %1$s?</string>
|
||||
<string name="phones_forget_text">The claw drops this phone\'s status, queued commands, and identity. A phone that still reports comes back on its next run.</string>
|
||||
<string name="phones_seen_just_now">last seen just now</string>
|
||||
<string name="phones_seen_fmt">last seen %1$s ago</string>
|
||||
<string name="phones_never_reported">never reported</string>
|
||||
<string name="phones_forgot_fmt">Forgot %1$s.</string>
|
||||
|
||||
<string name="card_reporting">Status reporting</string>
|
||||
<string name="reporting_hint">What this phone sends the claw</string>
|
||||
<string name="reporting_location_title">Location</string>
|
||||
<string name="reporting_location_desc">Where this phone is. A fix posts when it moves 300 m or more.</string>
|
||||
<string name="reporting_usage_title">Current app</string>
|
||||
<string name="reporting_usage_desc">The app in the foreground, through Android\'s usage access.</string>
|
||||
<string name="reporting_dnd_title">Do Not Disturb</string>
|
||||
<string name="reporting_dnd_desc">Lets the claw\'s dnd_on and dnd_off commands work.</string>
|
||||
<string name="reporting_on">on</string>
|
||||
<string name="reporting_allow">Allow</string>
|
||||
|
||||
<string name="sessions_group_interactive">Interactive</string>
|
||||
<string name="sessions_group_other">Other keys</string>
|
||||
|
||||
<string name="chat_agent">rhema</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Pre-Android-12 counterpart of data_extraction_rules.xml: the stored
|
||||
bearer token must not leave the device through adb/cloud backup. -->
|
||||
<full-backup-content>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Exclude everything, matching allowBackup=false: the stored bearer token
|
||||
must not leave the device through cloud backup or device transfer. -->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The cache subtree saved attachments land in before an ACTION_VIEW
|
||||
hand-off; FileProvider maps files under it to content:// uris. -->
|
||||
<paths>
|
||||
<cache-path name="attachments" path="attachments/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The claw is self-hosted: its address is whatever the operator points
|
||||
at — a LAN name like kosmos.lan, a bare IP, or a Tailscale name — and
|
||||
a publicly-trusted certificate is rarely available there. Cleartext is
|
||||
therefore permitted app-wide and user-installed CAs (mkcert) are
|
||||
trusted: only the operator's own claw is ever contacted, and the
|
||||
bearer token is the auth. Prefer HTTPS wherever the deployment has a
|
||||
real certificate. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
Reference in new issue
Block a user