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