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:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

@@ -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(),
)
}