feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user