feat(android): profile lock, voice fixes, diagnostics detail, in-app changelog, Play update nudge
Bumps appVersionName to 1.2.1 (versionCode 15). Added: - Profile lock (per-connection): pin to one profile and hide the rest; ProfileLockStore + ProfileController enforcement + Settings lock dialog with a not-found banner. - In-app What's New / changelog from a bundled changelog.json; revisitable Settings entry sharing one renderer with the auto post-update dialog. - Diagnostics detail view with Copy / Share / Create-GitHub-issue via a shared IssueReport helper (also adopted by the crash dialog); RelayErrorClassifier now records every classified error to DiagnosticsLog with a clean title + redacted stacktrace. - Update-available banner: googlePlay uses Play In-App Update (FLEXIBLE; new app-update dep, flavor-scoped), sideload uses the GitHub checker; per-version dismissal + 6h throttle, never nags. Fixed: - Voice override now applies in Auto mode (effectiveRoute gate) and voice prefs are namespaced by connectionId. - Realtime Stop halts playback immediately (suppress in-flight deltas); spoken-status throttle; client idle-watchdog relaxed on promoted/long runs. - Hold-to-talk releases only on a real finger-up; voice overlay panel + bubbles opaque with non-wrapping labels; invalid engine/route combos gated. - Connection status overlay terminal states auto-dismiss within ~5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a22bdd9488
commit
0aa1b38a18
@@ -13,6 +13,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
|
||||
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
|
||||
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
|
||||
- **Profile lock (Android).** Settings → Profile lock pins the app to a single agent profile and hides the rest from the pickers; the lock screen stays the one place that lists every profile, with a clear notice if the locked profile isn't on the current server.
|
||||
- **In-app What's New & changelog (Android).** A new Settings entry shows the current and past release notes any time — not just the post-update popup.
|
||||
- **Diagnostics: tap for detail + report (Android).** Logged errors now carry clean titles and open a detail view with Copy / Share / Create-GitHub-issue (the same flow as crash reports); classified errors across voice, chat, and connection are captured centrally.
|
||||
- **Update-available nudge (Android).** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -21,6 +25,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
- **Crash reports can be shared without GitHub.** The crash dialog now has a **Share** action alongside Copy and Report, handing the full report to the system share sheet (email, chat apps, notes, Drive). This covers users without a GitHub account and sideload installs that Play vitals never sees. Every outbound path stays user-initiated — nothing is sent automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice override applies in Auto mode (Android).** A chosen per-profile/enhanced voice now takes effect when the engine is on Auto with the relay paired — previously only "Relay" mode applied it. Per-profile voice settings are also namespaced by connection.
|
||||
- **Realtime voice "Stop" stops immediately (Android).** Tapping Stop while the agent is speaking now halts realtime playback at once; over-chatty spoken status is throttled; and long background tasks no longer time out the turn (relay keeps the session alive while the task runs).
|
||||
- **Hold-to-talk no longer releases on accidental drift (Android).** The mic button holds until the finger genuinely lifts, instead of cancelling when it drifts off the button.
|
||||
- **Voice overlay is readable (Android).** The voice dropdown panel and its status bubbles are opaque (no bleed-through), and the Focus/Overlay/Exit labels no longer wrap to two lines; invalid engine/route combinations are no longer selectable.
|
||||
- **Connection status overlay clears faster (Android).** Resolved (error/warning) connection toasts auto-dismiss within ~5s instead of lingering.
|
||||
|
||||
## [1.2.0] - 2026-06-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,11 +9,28 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
## User-Added:
|
||||
|
||||
- [ ] Enhance the 'clean chat' view mode to allow more a little more vertical visible text area and scrolling within.
|
||||
- [ ] Look into the voice-settings profile specific capabilities - confirm approach is sound - verify as I noticed that in 'auto' mode it didn't work, it still used the system default despite config despite override voice chosen being displayed to user in voice config in voice setting in app UI. Only switching to 'Relay' specifically allowed the user-override to work/apply.
|
||||
- [x] **Voice-settings profile override in 'auto' mode** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio. See DEVLOG + "Orchestration batch (2026-06-21)" below.)* Root cause: `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) not `.effectiveRoute` (resolved), so 'auto'+relay never engaged the override-capable relay path and fell back to host-global Standard `/api/audio/speak` (no override slot). Fixed + wired `connectionId` for per-profile voice-prefs namespacing. Original note: *Look into the voice-settings profile specific capabilities - in 'auto' mode the user-override voice wasn't applied (system default used) despite being displayed; only 'Relay' applied it.*
|
||||
|
||||
- [ ] - analytics and diagnostics pages need cleaned up, improved, enhancements for UI/UX/layout. Diagnostics should have timeline vertical status checks with failure reason etc
|
||||
- [ ] Analytics and diagnostics pages need cleaned up, improved, enhancements for UI/UX/layout. Diagnostics should have timeline vertical status checks with failure reason etc.
|
||||
- [x] **Realtime voice stall + over-chatty status** *(client half impl 2026-06-21, orchestration batch — unbuilt; server half deferred, see below.)* Client now relaxes the 90s idle watchdog on promoted/long runs (5-min backstop kept) and throttles spoken status (≥22s gap, ≤3/turn); realtime waveform now gates on real playback-start. Original note: *Realtime voice mode stalls/times-out when calling a background Hermes task and repeatedly reports status vocally when not necessary.*
|
||||
- [ ] Clean up connections page - reframe standard/vanilla Hermes as just 'Hermes' - relay enhanced connection becomes 'Relay' or 'Relay plugin' where descriptively appropriate.
|
||||
- [x] **Lock app to a specific profile** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio.)* Per-connection lock: new `ProfileLockStore`, `ProfileController` lock flows + enforcement, `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row, `SettingsScreen` adds the lock card + dialog (the one surface still listing all profiles). Original note: *Allow locking app to a specific profile, hiding all other profiles except from this setting - cleanly hide profile specific UI elements based on this gate.*
|
||||
- [x] **Profile icon in the floating voice overlay** *(impl 2026-06-21, orchestration batch — unbuilt.)* `VoiceModeOverlay` header pill now shows the per-profile icon (`LocalAgentIconPath`); sphere/pet stays the fallback.
|
||||
- [x] **Voice dropdown state mixes + label overflow** *(impl 2026-06-21, orchestration batch — unbuilt.)* Invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects); long dropdown/provider labels get `maxLines=1`+ellipsis. Original note: *Fix the voice dropdown mode toggles to not allow weird state mixes - labels need overflow control to prevent 2 lines or crunching.*
|
||||
|
||||
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 — `d827e46`, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
|
||||
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 —** `d827e46`**, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
|
||||
|
||||
## Orchestration batch (2026-06-21) — deferred follow-ups
|
||||
|
||||
Client-side profile-lock + voice fixes (the items marked above) landed via a planning→implementation orchestration pass, **built + deployed to device as 1.2.1 (versionCode 15)**; new unit suite green (36 Kotlin + 11 Python). On-device behaviour verification still pending. Remaining from that batch:
|
||||
|
||||
- **Realtime voice: server-side half (Python) — DONE + DEPLOYED 2026-06-21.** `plugin/relay/realtime_agent/broker.py`: `_send_hermes_run_progress` now heartbeats while `session.hermes_task` is unfinished (helper `_should_continue_heartbeat`), closing the 90s stall at the source; spoken-status repeat raised 30s→90s and gated on a *coarse* status change (`_coarse_spoken_status_key` / `_should_repeat_spoken_status`) so tool-message churn no longer re-narrates. `plugin/tests/test_realtime_heartbeat.py` 11/11; `test_realtime_promotion` regression 5/5. Deployed: committed `d1820fb` → pushed to `origin/dev` → server `~/.hermes/hermes-relay` fast-forwarded + `hermes-relay` restarted (active, clean startup) — both client + server halves now live end-to-end (re-pair the phone after the relay restart). Optional follow-up: flip `promotion_enabled` default to True so long runs detach.
|
||||
- **Voice override on the streaming path (open question).** The `.route`→`.effectiveRoute` fix makes 'auto'+relay engage the override-capable path, but the streaming `/voice/output` renderer reads the relay's server-saved `voice_output:` config, not the UI `enhancedVoice` override. Decide whether the override card should also push to `updateVoiceOutputConfig`, or whether an override should force the basic `/voice/synthesize` path.
|
||||
- **Per-profile voice on Standard (upstream).** `/api/audio/*` is host-global/text-only; the Standard surface still can't carry a per-request voice. Needs the upstream profile-voice / `/v1/audio/*` PR. Until then the client prefers the relay path; consider surfacing an honest "override needs Relay" state when Standard is the effective surface.
|
||||
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
|
||||
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
|
||||
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
|
||||
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a >90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
|
||||
@@ -164,7 +181,7 @@ Triggered by a Play Store review: app "keeps crashing" during setup on a Samsung
|
||||
|
||||
Follow-ups:
|
||||
|
||||
- **Confirm the actual crash from Play vitals.** Pull the top crash cluster for Galaxy Z Fold7 / version code 13 (Quality → Android vitals → Crashes & ANRs) to verify the camera path is the real cause vs. another setup-path throw. The hardening is correct regardless, but the trace closes the loop.
|
||||
- **Confirm the actual crash from Play vitals.** Pull the top crash cluster for Galaxy Z Fold7 / version code 13 (Quality → Android vitals → Crashes & ANRs) to verify the camera path is the real cause vs. another setup-path throw. The hardening is correct regardless, but the trace closes the loop.
|
||||
- **Portrait lock is moot on large screens under SDK 36.** `android:screenOrientation="portrait"` is largely ignored by Android 16's mandatory large-screen orientation override on foldables/tablets. Decide whether to keep the lock (it still applies on phones) or make it conditional; either way it does not *cause* the crash.
|
||||
- **Foldable camera lifecycle races (from the 2026-06-20 audit, not yet fixed).** `QrPairingScanner` can still hit bind/unbind races on rapid fold/unfold recomposition (the `DisposableEffect` `unbindAll()` vs. an in-flight `addListener` bind), and `mapBoxToViewport` runs on possibly-stale `viewportSizePx` during a fold transition. Not crash-fatal after the try/catch hardening (logged + skipped), but worth a fold-aware guard if foldable adoption grows.
|
||||
- **Optional: surface crash history in Settings.** The reporter keeps only the most recent crash (`files/crash/last-crash.json`, consumed on view). If repeat-crash diagnosis becomes common, keep a small ring of recent reports + a Settings entry to view/copy them.
|
||||
@@ -204,12 +221,13 @@ Follow-ups:
|
||||
- **Part-A chat polish (optional bundle).** Per-code-block copy + horizontal scroll, visible copy affordance, mid-stream stall feedback, profile/skill-aware empty-state chips, the ~40-flow recomposition hotspot at the top of `ChatScreen`. (Sphere `contentDescription`/reduced-motion was handled by the clean-mode a11y work.)
|
||||
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
|
||||
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
|
||||
- **`adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
|
||||
- **Pet state-change re-decode can flash one blank frame.** When the agent state switches clips, the first frame of the new clip may briefly be blank during decode; prewarm/hold-last-frame to smooth it. Root cause is the same as the next item: `PetAvatar.Render` re-decodes from disk on every clip change.
|
||||
- **Pet frame-sequence memory: no cap or downsample (audit 2026-06-19).** `decodeClip` decodes every frame of the selected clip into `List<ImageBitmap>` at full resolution with no `inSampleSize` downscale to the display size and no frame-count/dimension ceiling — a long sequence of large PNGs can use a lot of RAM and a single very large image can OOM `BitmapFactory`. Add `inSampleSize` downsampling to the avatar's draw size and/or a documented hard cap. Spec now warns authors (prefer sprite sheets), but the renderer doesn't enforce it.
|
||||
- **Pet decoded-clip cache (audit 2026-06-19).** `PetAvatar.Render` keys `produceState` on `clip`, so idle→thinking→speaking→idle within one turn re-runs `BitmapFactory.decodeFile` from disk each transition (repeated I/O + GC churn, and the blank-frame flash above). Add a small per-avatar `Map<SphereState, PetFrames>` decode cache.
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
|
||||
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Watch for the known clip re-decode flash on each swap (separate TODO — decoded-clip cache).
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
|
||||
|
||||
@@ -254,6 +254,15 @@ dependencies {
|
||||
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
|
||||
implementation(libs.android.vad.silero)
|
||||
|
||||
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
|
||||
// Scoped via the `googlePlayImplementation` configuration so it never
|
||||
// ships in the sideload APK, which updates via the GitHub-releases
|
||||
// UpdateChecker instead. The `app/src/googlePlay/.../update/` impl
|
||||
// references AppUpdateManager; the `app/src/sideload/.../update/` impl
|
||||
// never touches this library.
|
||||
"googlePlayImplementation"(libs.play.app.update)
|
||||
"googlePlayImplementation"(libs.play.app.update.ktx)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.play.core.appupdate.AppUpdateInfo
|
||||
import com.google.android.play.core.appupdate.AppUpdateManager
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||
import com.google.android.play.core.appupdate.AppUpdateOptions
|
||||
import com.google.android.play.core.install.InstallState
|
||||
import com.google.android.play.core.install.InstallStateUpdatedListener
|
||||
import com.google.android.play.core.install.model.AppUpdateType
|
||||
import com.google.android.play.core.install.model.InstallStatus
|
||||
import com.google.android.play.core.install.model.UpdateAvailability
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* === update (googlePlay flavor): factory ===
|
||||
*
|
||||
* Backs [UpdateAvailabilitySource] onto Google Play's In-App Update API,
|
||||
* FLEXIBLE flow. Mirrors `voice/VoiceBridgeIntentFactory`'s flavor-split
|
||||
* factory pattern: both flavors export this exact function signature +
|
||||
* package, so the UI layer has one static call site and no reflection / no
|
||||
* `#if` gating.
|
||||
*/
|
||||
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
|
||||
PlayUpdateAvailabilitySource(context.applicationContext)
|
||||
|
||||
private const val TAG = "PlayUpdate"
|
||||
|
||||
/**
|
||||
* Google Play FLEXIBLE in-app update source.
|
||||
*
|
||||
* - [check] queries `AppUpdateManager.appUpdateInfo`. If Play reports
|
||||
* `UPDATE_AVAILABLE` and FLEXIBLE is allowed, returns [UpdateStatus.Available]
|
||||
* (or [UpdateStatus.Downloaded] / [UpdateStatus.Downloading] if a previously
|
||||
* started flexible update is already mid-flight). Anything else →
|
||||
* [UpdateStatus.UpToDate].
|
||||
* - [startUpdate] launches Play's FLEXIBLE consent + background download and
|
||||
* registers an [InstallStateUpdatedListener] so DOWNLOADED is reported back
|
||||
* asynchronously via [onStatusChanged].
|
||||
* - [completeUpdate] calls `AppUpdateManager.completeUpdate()` which restarts
|
||||
* the app to install the staged APK.
|
||||
*
|
||||
* Robustness: every Play interaction is wrapped in try/catch. On any failure
|
||||
* (no Play services, sideloaded "googlePlay" build on an AOSP device, RESULT
|
||||
* errors) it degrades to [UpdateStatus.UpToDate] / [UpdateStatus.Unsupported]
|
||||
* — the banner just never shows. Play is never a crash surface.
|
||||
*/
|
||||
private class PlayUpdateAvailabilitySource(
|
||||
private val appContext: Context,
|
||||
) : UpdateAvailabilitySource {
|
||||
|
||||
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
|
||||
|
||||
private val manager: AppUpdateManager? = runCatching {
|
||||
AppUpdateManagerFactory.create(appContext)
|
||||
}.getOrNull()
|
||||
|
||||
/** Cached label/code from the last [check] so async listener events can label themselves. */
|
||||
@Volatile private var lastVersionCode: Long? = null
|
||||
|
||||
private val installListener = InstallStateUpdatedListener { state: InstallState ->
|
||||
when (state.installStatus()) {
|
||||
InstallStatus.DOWNLOADING ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
// bytesDownloaded()/totalBytesToDownload() are base
|
||||
// app-update InstallState methods (Long); no ktx import.
|
||||
bytesDownloaded = state.bytesDownloaded(),
|
||||
totalBytes = state.totalBytesToDownload(),
|
||||
)
|
||||
)
|
||||
InstallStatus.DOWNLOADED ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
)
|
||||
else -> Unit // INSTALLING / INSTALLED / FAILED / CANCELED → no banner change
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var listenerRegistered = false
|
||||
|
||||
override suspend fun check(): UpdateStatus {
|
||||
val mgr = manager ?: return UpdateStatus.Unsupported
|
||||
return try {
|
||||
val info = mgr.awaitAppUpdateInfo()
|
||||
lastVersionCode = info.availableVersionCode().toLong()
|
||||
when {
|
||||
// A previously started FLEXIBLE update already finished downloading.
|
||||
info.installStatus() == InstallStatus.DOWNLOADED -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
|
||||
info.installStatus() == InstallStatus.DOWNLOADING -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) ->
|
||||
UpdateStatus.Available(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
openUrl = null,
|
||||
)
|
||||
else -> UpdateStatus.UpToDate
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "appUpdateInfo check failed; treating as up-to-date", t)
|
||||
UpdateStatus.UpToDate
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdate(activity: Activity?): Boolean {
|
||||
val mgr = manager ?: return false
|
||||
if (activity == null) return false
|
||||
return try {
|
||||
ensureListener(mgr)
|
||||
mgr.appUpdateInfo
|
||||
.addOnSuccessListener { info: AppUpdateInfo ->
|
||||
val canStart = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
|
||||
val resuming = info.updateAvailability() ==
|
||||
UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
|
||||
if (canStart || resuming) {
|
||||
runCatching {
|
||||
mgr.startUpdateFlow(
|
||||
info,
|
||||
activity,
|
||||
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "startUpdateFlow failed", it) }
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { Log.w(TAG, "startUpdate appUpdateInfo failed", it) }
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startUpdate failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun completeUpdate() {
|
||||
val mgr = manager ?: return
|
||||
runCatching { mgr.completeUpdate() }
|
||||
.onFailure { Log.w(TAG, "completeUpdate failed", it) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val mgr = manager ?: return
|
||||
if (listenerRegistered) {
|
||||
runCatching { mgr.unregisterListener(installListener) }
|
||||
listenerRegistered = false
|
||||
}
|
||||
onStatusChanged = null
|
||||
}
|
||||
|
||||
private fun ensureListener(mgr: AppUpdateManager) {
|
||||
if (!listenerRegistered) {
|
||||
runCatching { mgr.registerListener(installListener) }
|
||||
.onSuccess { listenerRegistered = true }
|
||||
.onFailure { Log.w(TAG, "registerListener failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Play exposes only the numeric versionCode, not a marketing version
|
||||
// string, so the banner copy stays generic ("A new version"). The code is
|
||||
// still carried on the status for per-version dismissal keying.
|
||||
private fun labelFor(@Suppress("UNUSED_PARAMETER") code: Long?): String = "A new version"
|
||||
}
|
||||
|
||||
// === END update (googlePlay) ===
|
||||
|
||||
/**
|
||||
* `await()` for Play's [AppUpdateInfo] task without pulling in
|
||||
* `kotlinx-coroutines-play-services`. Named `await…` (not the ktx
|
||||
* `requestAppUpdateInfo`) to avoid any overload ambiguity with the
|
||||
* `app-update-ktx` suspend extension. Resumable + cancels cleanly if the
|
||||
* coroutine is torn down.
|
||||
*/
|
||||
private suspend fun AppUpdateManager.awaitAppUpdateInfo(): AppUpdateInfo =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
appUpdateInfo
|
||||
.addOnSuccessListener { info -> if (cont.isActive) cont.resume(info) }
|
||||
.addOnFailureListener { e -> if (cont.isActive) cont.cancel(e) }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
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.map
|
||||
|
||||
/**
|
||||
* Per-connection persisted "profile lock" — pins the app to ONE Hermes
|
||||
* profile so the profile pickers/switchers across the app collapse to a
|
||||
* single locked state. A dedicated Settings control is the only surface that
|
||||
* still lists every profile (to choose the lock target or unlock).
|
||||
*
|
||||
* Twin of [ProfileSelectionStore]: this deliberately rides the SAME
|
||||
* [profileSelectionsDataStore] ("profile_selections") so the lock and the
|
||||
* selection clear and migrate together — a per-connection wipe or a wholesale
|
||||
* reset takes out both, and there is no second DataStore file to keep in sync.
|
||||
*
|
||||
* Value semantics (distinct from "selection", which is just a name or absent):
|
||||
* - **absent key** → unlocked. The flow emits `null`. This is distinct from
|
||||
* "locked to Server default", so we can tell "no lock" apart from "lock to
|
||||
* the server's own default profile".
|
||||
* - [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] sentinel → locked to **Server
|
||||
* default** (the null-profile context). Reusing the existing sentinel keeps
|
||||
* the server-default identity consistent with [AgentDisplay.profileSessionKey].
|
||||
* - any other string → locked to that profile `name`.
|
||||
*
|
||||
* The caller ([com.hermesandroid.relay.viewmodel.connection.ProfileController])
|
||||
* resolves the locked name against the current server-advertised profile list;
|
||||
* if the locked profile no longer exists it HOLDS (selection null) and surfaces
|
||||
* a banner rather than silently switching.
|
||||
*/
|
||||
class ProfileLockStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSelectionsDataStore)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Preference-key factory. Per-connection so every connection gets its
|
||||
* own lock slot — profiles are server-scoped, so a lock pinned on one
|
||||
* server must not leak onto another.
|
||||
*/
|
||||
private fun keyFor(connectionId: String) =
|
||||
stringPreferencesKey("locked_profile_$connectionId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the lock for [connectionId].
|
||||
* - `null` → **unlock**: removes the key (converges with fresh-install
|
||||
* "no key" state).
|
||||
* - any non-null [profileName] → lock to that profile name. Callers lock
|
||||
* to Server default by passing [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY].
|
||||
*/
|
||||
suspend fun setLockedProfile(connectionId: String, profileName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId)
|
||||
if (profileName == null) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = profileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the locked profile name for [connectionId], or `null` when no lock
|
||||
* is stored (unlocked). The sentinel
|
||||
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] means "locked to Server default".
|
||||
*/
|
||||
fun lockedProfileFlow(connectionId: String): Flow<String?> {
|
||||
val key = keyFor(connectionId)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the persisted lock for [connectionId]. Called from the connection
|
||||
* removal path alongside the selection clear so a removed connection's lock
|
||||
* pointer goes with it.
|
||||
*/
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,21 @@ data class DiagnosticLogEntry(
|
||||
val endpointRole: String? = null,
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
/**
|
||||
* Full (multi-KB) redacted stacktrace for the detail page. Kept OUT of the
|
||||
* 180-char [detail] truncation — the list still shows the short title/detail,
|
||||
* the detail view shows this. Null for non-error / manually-recorded entries.
|
||||
*/
|
||||
val stacktrace: String? = null,
|
||||
)
|
||||
|
||||
object DiagnosticsLog {
|
||||
private const val MAX_ENTRIES = 200
|
||||
private const val MAX_TEXT_LENGTH = 180
|
||||
|
||||
/** Cap for the full stacktrace kept on an error entry — a few KB is plenty. */
|
||||
private const val MAX_TRACE_LENGTH = 8000
|
||||
|
||||
private val lock = Any()
|
||||
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
|
||||
@@ -46,6 +55,7 @@ object DiagnosticsLog {
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
stacktrace: String? = null,
|
||||
) {
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
@@ -56,12 +66,51 @@ object DiagnosticsLog {
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = redactTrace(stacktrace),
|
||||
)
|
||||
synchronized(lock) {
|
||||
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an [DiagnosticSeverity.Error] entry from a classified failure. The
|
||||
* list keeps showing the clean [title] (+ short [detail]); the detail page
|
||||
* shows the full redacted stacktrace.
|
||||
*
|
||||
* Called centrally from [com.hermesandroid.relay.util.classifyError] as a
|
||||
* side effect, so every classified error lands here with no per-call-site
|
||||
* churn. The flow is one-way (classify -> record); nothing here re-enters
|
||||
* the classifier, so there is no recursion.
|
||||
*
|
||||
* @param title clean, human title (e.g. [com.hermesandroid.relay.util.HumanError.title]).
|
||||
* @param detail short one-line summary shown in the list row (truncated to 180).
|
||||
* @param throwable source error — its stacktrace is captured, redacted, and capped.
|
||||
*/
|
||||
fun recordError(
|
||||
category: DiagnosticCategory,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
throwable: Throwable? = null,
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
) {
|
||||
record(
|
||||
category = category,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = title,
|
||||
detail = detail ?: throwable?.message,
|
||||
endpointRole = endpointRole,
|
||||
url = url,
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = throwable?.let { stackTraceText(it) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun stackTraceText(t: Throwable): String =
|
||||
java.io.StringWriter().also { t.printStackTrace(java.io.PrintWriter(it)) }.toString().trim()
|
||||
|
||||
fun recent(
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 30,
|
||||
@@ -101,10 +150,26 @@ object DiagnosticsLog {
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return trimmed
|
||||
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
.take(MAX_TEXT_LENGTH)
|
||||
return redact(trimmed).take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same secret redaction as [clean] but WITHOUT the 180-char list truncation —
|
||||
* for the full stacktrace shown on the detail page. Still capped at
|
||||
* [MAX_TRACE_LENGTH] so a runaway trace can't bloat the ring.
|
||||
*/
|
||||
private fun redactTrace(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val redacted = redact(trimmed)
|
||||
return if (redacted.length > MAX_TRACE_LENGTH) {
|
||||
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
|
||||
} else {
|
||||
redacted
|
||||
}
|
||||
}
|
||||
|
||||
private fun redact(value: String): String =
|
||||
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1270,6 +1270,13 @@ class RelayVoiceClient(
|
||||
// True while a turn is awaiting its response. In persistent mode the idle
|
||||
// guard only applies while a turn is active; between-turn idle is normal.
|
||||
val activeTurn = AtomicBoolean(true)
|
||||
// W3: set true once a turn is known to be a long/background Hermes run
|
||||
// (e.g. `hermes.run.promoted`). The relay can legitimately go quiet for
|
||||
// minutes while such a run executes, so the 90s idle guard would kill an
|
||||
// otherwise-healthy turn. When set, the idle check is paused the same way
|
||||
// persistent between-turn idle is — REALTIME_AGENT_MAX_TURN_MS remains
|
||||
// the absolute backstop. Reset at every turn boundary.
|
||||
val longRunningTurn = AtomicBoolean(false)
|
||||
val inputChunks = buildList {
|
||||
var offset = 0
|
||||
var chunkId = 1L
|
||||
@@ -1306,6 +1313,7 @@ class RelayVoiceClient(
|
||||
turnStartedAtMs.set(System.currentTimeMillis())
|
||||
lastEventAtMs.set(System.currentTimeMillis())
|
||||
activeTurn.set(true)
|
||||
longRunningTurn.set(false)
|
||||
}
|
||||
fun activateSocket(webSocket: WebSocket, generation: Long): Boolean {
|
||||
while (true) {
|
||||
@@ -1440,6 +1448,13 @@ class RelayVoiceClient(
|
||||
lastPlayedAudioEventId.updateAndGet { current -> maxOf(current, playedAudioEventId) }
|
||||
}
|
||||
onEvent(event, control)
|
||||
// W3: a promoted (background) Hermes run can legitimately
|
||||
// leave the socket quiet for minutes. Flag the turn so the
|
||||
// idle guard relaxes; MAX_TURN_MS still bounds it.
|
||||
if (event.type == "hermes.run.promoted") {
|
||||
longRunningTurn.set(true)
|
||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||
}
|
||||
if (event.isAudioDelta) {
|
||||
audioChunks += 1
|
||||
val byteCount = event.byteCount ?: 0
|
||||
@@ -1468,6 +1483,7 @@ class RelayVoiceClient(
|
||||
// Turn boundary, not session boundary: keep the socket
|
||||
// open for the next utterance.
|
||||
activeTurn.set(false)
|
||||
longRunningTurn.set(false)
|
||||
onTurnComplete(summary)
|
||||
} else {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
@@ -1588,14 +1604,26 @@ class RelayVoiceClient(
|
||||
if (turnElapsedMs >= REALTIME_AGENT_MAX_TURN_MS) {
|
||||
throw IOException("Realtime agent exceeded the turn limit")
|
||||
}
|
||||
if (idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
|
||||
// W3: for a known long/background run the relay can go quiet
|
||||
// for minutes — pause the idle guard the same way persistent
|
||||
// between-turn idle is paused, keeping only the MAX_TURN_MS
|
||||
// backstop above.
|
||||
val idleGuardActive = !longRunningTurn.get()
|
||||
if (idleGuardActive && idleElapsedMs >= REALTIME_AGENT_IDLE_TIMEOUT_MS) {
|
||||
throw IOException("Realtime agent stalled waiting for relay events")
|
||||
}
|
||||
val waitMs = minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
val waitMs = if (idleGuardActive) {
|
||||
minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
REALTIME_AGENT_IDLE_TIMEOUT_MS - idleElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
} else {
|
||||
minOf(
|
||||
REALTIME_AGENT_WAIT_SLICE_MS,
|
||||
REALTIME_AGENT_MAX_TURN_MS - turnElapsedMs,
|
||||
).coerceAtLeast(1L)
|
||||
}
|
||||
withTimeoutOrNull(waitMs) {
|
||||
finished.await()
|
||||
}?.let { return it }
|
||||
@@ -1626,6 +1654,7 @@ class RelayVoiceClient(
|
||||
turnStartedAtMs.set(System.currentTimeMillis())
|
||||
lastEventAtMs.set(System.currentTimeMillis())
|
||||
activeTurn.set(true)
|
||||
longRunningTurn.set(false)
|
||||
} else {
|
||||
sendTurnPcm(ws, turn.inputPcm, turn.sampleRate)
|
||||
}
|
||||
|
||||
@@ -90,10 +90,9 @@ import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateBanner
|
||||
import com.hermesandroid.relay.ui.components.UpdateAvailableBanner
|
||||
import com.hermesandroid.relay.ui.components.rememberUpdateAvailability
|
||||
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
|
||||
import com.hermesandroid.relay.update.UpdateCheckResult
|
||||
import com.hermesandroid.relay.viewmodel.UpdateViewModel
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewDialog
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
@@ -327,7 +326,6 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = viewModel()
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = viewModel()
|
||||
val updateViewModel: UpdateViewModel = viewModel()
|
||||
|
||||
// Composition-scoped coroutine scope for firing connection-store suspend
|
||||
// writes off of UI click handlers (rename/revoke/remove) —
|
||||
@@ -681,7 +679,12 @@ fun RelayApp() {
|
||||
chatViewModel.refreshSessions()
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProfile?.name) {
|
||||
LaunchedEffect(activeConnectionId, selectedProfile?.name) {
|
||||
// WP-V2: namespace per-profile voice prefs by BOTH the active connection
|
||||
// and the profile so two connections exposing a same-named profile don't
|
||||
// collide. Set the connection id first so onProfileChanged re-seeds from
|
||||
// the correctly-scoped keys.
|
||||
voiceViewModel.setVoicePrefsConnection(activeConnectionId)
|
||||
voiceViewModel.onProfileChanged(
|
||||
AgentDisplay.profileRequestName(selectedProfile?.name)
|
||||
)
|
||||
@@ -1221,11 +1224,12 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
// Sideload-only update availability (UpdateViewModel short-circuits on
|
||||
// googlePlay). Hoisted to the outer scope so the update toast can render
|
||||
// in the floating Box overlay below alongside the connection toast.
|
||||
val updateBannerState by updateViewModel.bannerState.collectAsState()
|
||||
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
|
||||
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
|
||||
// sideload = GitHub releases. The handle filters dismissed versions +
|
||||
// throttles checks internally, exposing a surfaceable status for the
|
||||
// floating overlay (mirrors the connection toast treatment).
|
||||
val updateHandle = rememberUpdateAvailability()
|
||||
val availableUpdateStatus by updateHandle.visibleStatus
|
||||
|
||||
// Content-identity key so a swipe-up dismiss sticks for THIS status but
|
||||
// a genuinely new status (different title/tone/phase) re-shows.
|
||||
@@ -1772,6 +1776,7 @@ fun RelayApp() {
|
||||
VoiceSettingsScreen(
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
connectionId = activeConnectionId,
|
||||
selectedProfile = selectedProfile,
|
||||
standardVoiceAvailability = standardVoiceAvailability,
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
@@ -2191,15 +2196,17 @@ fun RelayApp() {
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = availableUpdate != null && !suppressGlobalChrome &&
|
||||
visible = availableUpdateStatus != null && !suppressGlobalChrome &&
|
||||
!showStartupSphere && !voiceUiState.voiceMode,
|
||||
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
|
||||
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
|
||||
) {
|
||||
availableUpdate?.let { upd ->
|
||||
UpdateBanner(
|
||||
update = upd,
|
||||
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
|
||||
availableUpdateStatus?.let { status ->
|
||||
UpdateAvailableBanner(
|
||||
status = status,
|
||||
onUpdate = updateHandle.onUpdateClick,
|
||||
onDismiss = updateHandle.onDismiss,
|
||||
includeStatusBarPadding = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -645,6 +646,11 @@ fun AgentInfoSheet(
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||
// Profile lock — when set, the picker below collapses to a single static
|
||||
// "Locked to <name>" row. Only the dedicated Settings control still lists
|
||||
// every profile (to change the lock target or unlock).
|
||||
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
|
||||
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
@@ -836,6 +842,26 @@ fun AgentInfoSheet(
|
||||
?: "Server default",
|
||||
) {
|
||||
|
||||
if (isProfileLocked) {
|
||||
// Pinned to one profile — collapse the whole radio list to a
|
||||
// single static, non-interactive row. The lock target is the
|
||||
// raw stored token: the sentinel means Server default, any
|
||||
// other value is a profile name (resolved to its display name).
|
||||
val lockedDisplayName = when {
|
||||
lockedProfileName == null ->
|
||||
"Server default"
|
||||
AgentDisplay.isServerDefaultAlias(lockedProfileName) ||
|
||||
lockedProfileName == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY ->
|
||||
"Server default"
|
||||
else ->
|
||||
agentProfiles
|
||||
.firstOrNull { it.name == lockedProfileName }
|
||||
?.let { AgentDisplay.profileDisplayName(it) }
|
||||
?: lockedProfileName!!.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
LockedProfileRow(lockedDisplayName = lockedDisplayName)
|
||||
} else {
|
||||
|
||||
val defaultDotColor = serverDefaultProfile?.let { profile ->
|
||||
if (profile.gatewayRunning) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
@@ -1063,6 +1089,7 @@ fun AgentInfoSheet(
|
||||
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
|
||||
)
|
||||
}
|
||||
} // end else (not locked)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
@@ -1884,6 +1911,42 @@ private fun ProfileRadioRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single static, non-interactive row shown in place of the profile radio list
|
||||
* when the connection is locked to one profile. There is intentionally no
|
||||
* onSelect — the only way to change the target or unlock is the dedicated
|
||||
* "Profile lock" control in Settings, which always lists every profile.
|
||||
*/
|
||||
@Composable
|
||||
private fun LockedProfileRow(lockedDisplayName: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Locked to $lockedDisplayName",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = "Manage the lock in Settings → Profile lock",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lines a collapsed (truncated) [ProfileRadioRow] description shows before its
|
||||
* tap-to-expand affordance reveals the rest. Two keeps the badge FlowRow on
|
||||
* screen even when the description is long. */
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -49,6 +46,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.util.CrashReport
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -150,26 +148,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(
|
||||
// FlowRow so the actions wrap instead of clipping on narrow /
|
||||
// foldable cover screens now that a fourth (Share) action exists.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
copyToClipboard(context, reportText)
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Crash report copied")
|
||||
},
|
||||
) { Text("Copy") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
"Hermes-Relay crash report — ${report.shortTitle()}",
|
||||
reportText,
|
||||
chooserTitle = "Share crash report",
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Report copied — no app found to share to")
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Share") }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy the FULL report first; the URL only carries the
|
||||
// head of the trace, so the user can paste the rest.
|
||||
copyToClipboard(context, reportText)
|
||||
val opened = openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full report copied — paste into the issue if it's truncated"
|
||||
@@ -184,20 +201,6 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
runCatching {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Hermes-Relay crash report", text))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(context: Context, url: String): Boolean = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun toast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
/**
|
||||
* Self-contained, full-detail view for a single [DiagnosticLogEntry], opened
|
||||
* from a tapped row in [DiagnosticsLogPanel]. Renders the clean title, category,
|
||||
* severity, timestamp, sanitized route/url, elapsed, and the full redacted
|
||||
* stacktrace/detail in a monospace selectable block.
|
||||
*
|
||||
* It is a plain [Dialog] driven entirely by the panel's own state — there is NO
|
||||
* nav route and nothing to wire in RelayApp. Visual pattern mirrors
|
||||
* [CrashReportDialog]; the Copy / Export(share) / Create-GitHub-issue actions
|
||||
* all route through the shared [IssueReport] helper.
|
||||
*/
|
||||
@Composable
|
||||
fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val plainText = remember(entry) { entry.toPlainText() }
|
||||
val severityName = entry.severity.name
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = entry.category.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// Metadata rows — only render the ones that are present.
|
||||
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
|
||||
MetaRow("Severity", severityName)
|
||||
MetaRow("Category", entry.category.label)
|
||||
entry.endpointRole?.let { MetaRow("Route", it) }
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
val body = entry.stacktrace ?: entry.detail
|
||||
if (body != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 320.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = body,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "No further detail captured for this entry.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Diagnostic copied")
|
||||
},
|
||||
) { Text("Copy") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
subject = "Hermes-Relay diagnostic — ${entry.title}",
|
||||
text = plainText,
|
||||
chooserTitle = "Export diagnostic",
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Copied — no app found to share to")
|
||||
}
|
||||
},
|
||||
) { Text("Export") }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy full text first; the GitHub URL only carries the
|
||||
// head of long traces, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
val opened = IssueReport.openUrl(
|
||||
context,
|
||||
IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: ${entry.title}",
|
||||
bodyMarkdown = entry.toIssueBody(),
|
||||
labels = "bug",
|
||||
),
|
||||
)
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
|
||||
else "Copied — no browser found to open GitHub",
|
||||
)
|
||||
},
|
||||
) { Text("Report") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(78.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
|
||||
val (bg, fg) = when (severity) {
|
||||
DiagnosticSeverity.Info ->
|
||||
MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
|
||||
DiagnosticSeverity.Warning ->
|
||||
MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
|
||||
DiagnosticSeverity.Error ->
|
||||
MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(50), color = bg) {
|
||||
Text(
|
||||
text = severity.name.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = fg,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: android.content.Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
|
||||
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
appendLine("Hermes-Relay diagnostic")
|
||||
appendLine("Title: $title")
|
||||
appendLine("Category: ${category.label}")
|
||||
appendLine("Severity: ${severity.name}")
|
||||
appendLine("Time: ${DateFormat.format("yyyy-MM-dd HH:mm:ss", timestampMs)}")
|
||||
appendLine("App: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR}")
|
||||
endpointRole?.let { appendLine("Route: $it") }
|
||||
url?.let { appendLine("URL: $it") }
|
||||
elapsedMs?.let { appendLine("Elapsed: ${it}ms") }
|
||||
detail?.let {
|
||||
appendLine()
|
||||
appendLine("Detail:")
|
||||
appendLine(it)
|
||||
}
|
||||
stacktrace?.let {
|
||||
appendLine()
|
||||
appendLine("Stacktrace:")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown issue body mirroring the crash-report issue format: environment block
|
||||
* + the captured entry. Trace is capped so the prefilled GitHub URL stays within
|
||||
* browser limits (full text is on the clipboard).
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private fun DiagnosticLogEntry.toIssueBody(): String {
|
||||
val trace = (stacktrace ?: detail).orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full diagnostic copied to your clipboard)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val surface = if (BuildConfig.FLAVOR.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play"
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine("Captured diagnostic from the in-app activity log.")
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine("- Hermes-Relay version/tag: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})")
|
||||
appendLine("- Install surface: $surface")
|
||||
appendLine("- Connection mode: LAN / Tailscale / public TLS / other")
|
||||
appendLine()
|
||||
appendLine("### Diagnostic")
|
||||
appendLine("- Title: $title")
|
||||
appendLine("- Category: ${category.label}")
|
||||
appendLine("- Severity: ${severity.name}")
|
||||
endpointRole?.let { appendLine("- Route: $it") }
|
||||
url?.let { appendLine("- URL: $it") }
|
||||
elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
}
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
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.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -21,6 +24,9 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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
|
||||
@@ -42,11 +48,20 @@ fun DiagnosticsLogPanel(
|
||||
limit: Int = 8,
|
||||
showCategory: Boolean = false,
|
||||
showClear: Boolean = false,
|
||||
showSeverityFilter: Boolean = false,
|
||||
) {
|
||||
val entries by DiagnosticsLog.entries.collectAsState()
|
||||
|
||||
// Self-contained detail-view state — tapping a row opens DiagnosticDetailDialog.
|
||||
// No nav route; nothing to wire in RelayApp.
|
||||
var selected by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
|
||||
// Optional severity filter, local to the panel (null = all severities).
|
||||
var severityFilter by remember { mutableStateOf<DiagnosticSeverity?>(null) }
|
||||
|
||||
val visible = entries
|
||||
.asReversed()
|
||||
.filter { categories == null || it.category in categories }
|
||||
.filter { severityFilter == null || it.severity == severityFilter }
|
||||
.take(limit.coerceAtLeast(0))
|
||||
|
||||
Column(
|
||||
@@ -70,6 +85,23 @@ fun DiagnosticsLogPanel(
|
||||
}
|
||||
}
|
||||
|
||||
if (showSeverityFilter) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = severityFilter == null,
|
||||
onClick = { severityFilter = null },
|
||||
label = { Text("All") },
|
||||
)
|
||||
DiagnosticSeverity.entries.forEach { sev ->
|
||||
FilterChip(
|
||||
selected = severityFilter == sev,
|
||||
onClick = { severityFilter = if (severityFilter == sev) null else sev },
|
||||
label = { Text(sev.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Text(
|
||||
text = "No recent activity",
|
||||
@@ -89,6 +121,7 @@ fun DiagnosticsLogPanel(
|
||||
showCategory = showCategory,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { selected = entry }
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
)
|
||||
if (index != visible.lastIndex) {
|
||||
@@ -99,6 +132,10 @@ fun DiagnosticsLogPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selected?.let { entry ->
|
||||
DiagnosticDetailDialog(entry = entry, onDismiss = { selected = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -141,6 +178,9 @@ private fun DiagnosticLogRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (entry.severity != DiagnosticSeverity.Info) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
}
|
||||
Text(
|
||||
text = DateFormat.format("HH:mm:ss", entry.timestampMs).toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.update.UpdateAvailabilitySource
|
||||
import com.hermesandroid.relay.update.UpdateDismissalPreferences
|
||||
import com.hermesandroid.relay.update.UpdateStatus
|
||||
import com.hermesandroid.relay.update.createUpdateAvailabilitySource
|
||||
import com.hermesandroid.relay.update.dismissKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Auto-check interval — both flavors. App cold-starts / resumes more often
|
||||
* than this don't need a fresh Play/GitHub round-trip.
|
||||
*/
|
||||
private const val AUTO_CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Handle returned by [rememberUpdateAvailability] for the host
|
||||
* (`RelayApp.kt`) to drive the banner. The scaffold renders
|
||||
* [UpdateAvailableBanner] when [visibleStatus] is a surfaceable status, and
|
||||
* calls [onUpdateClick] / [onDismiss] from the banner's actions.
|
||||
*
|
||||
* `visibleStatus` is already filtered through the per-version dismiss
|
||||
* preference: a dismissed [UpdateStatus.Available] reads as null here, but a
|
||||
* [UpdateStatus.Downloaded] (FLEXIBLE finished while in-app) is intentionally
|
||||
* NOT suppressible — "restart to finish" should always be offered.
|
||||
*/
|
||||
class UpdateAvailabilityHandle internal constructor(
|
||||
val visibleStatus: State<UpdateStatus?>,
|
||||
/**
|
||||
* Primary banner action. For [UpdateStatus.Downloaded] this completes +
|
||||
* restarts (Play); otherwise it starts the update (Play FLEXIBLE flow /
|
||||
* sideload browser open). The hosting Activity is captured internally by
|
||||
* [rememberUpdateAvailability] — the coordinator just calls this.
|
||||
*/
|
||||
val onUpdateClick: () -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Lifecycle-bound entry point the coordinator wires once from inside the
|
||||
* `RelayApp` composable. Builds the per-flavor [UpdateAvailabilitySource]
|
||||
* (googlePlay = Play In-App Update FLEXIBLE; sideload = GitHub releases),
|
||||
* throttle-checks on first composition + every ON_RESUME, listens for the
|
||||
* async Play DOWNLOADED transition, and exposes a [UpdateAvailabilityHandle].
|
||||
*
|
||||
* Wiring (host side, NOT done here):
|
||||
* ```
|
||||
* val update = rememberUpdateAvailability()
|
||||
* val status by update.visibleStatus
|
||||
* // inside the top overlay Column, alongside ConnectionStatusToast:
|
||||
* AnimatedVisibility(visible = status != null && !suppressGlobalChrome && …) {
|
||||
* status?.let { UpdateAvailableBanner(
|
||||
* status = it,
|
||||
* onUpdate = { update.onUpdateClick(activity) },
|
||||
* onDismiss = update.onDismiss,
|
||||
* ) }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Place the call near the other `viewModel()` hoists at the top of `RelayApp`;
|
||||
* render the banner in the existing floating top-overlay Column so it slides
|
||||
* over content without resizing it (same treatment as the connection toast).
|
||||
*/
|
||||
@Composable
|
||||
fun rememberUpdateAvailability(): UpdateAvailabilityHandle {
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Resolve the hosting Activity for the Play FLEXIBLE consent dialog.
|
||||
// Tracked live so a config-change recomposition re-binds the new Activity.
|
||||
val activityState = rememberUpdatedState(context.findActivity())
|
||||
|
||||
val source = remember(appContext) { createUpdateAvailabilitySource(appContext) }
|
||||
|
||||
// Raw, unfiltered status from the source (check result + async listener).
|
||||
var rawStatus by remember { mutableStateOf<UpdateStatus>(UpdateStatus.UpToDate) }
|
||||
|
||||
// Per-version dismissal. dismissedKey is observed so a fresh dismiss takes
|
||||
// effect immediately; a strictly-newer offer re-shows automatically.
|
||||
val dismissedKey by UpdateDismissalPreferences
|
||||
.dismissedKey(appContext)
|
||||
.collectAsState(initial = null)
|
||||
|
||||
// Visible status = raw, but Available/Downloading suppressed when dismissed.
|
||||
// Downloaded is never suppressed (restart prompt must always show).
|
||||
// derivedStateOf tracks both snapshot inputs (rawStatus + the collected
|
||||
// dismissedKey) so the handle (built once) reads live updates. The
|
||||
// dismiss check is a pure function (no I/O), safe inside the derivation.
|
||||
val dismissedKeyState = rememberUpdatedState(dismissedKey)
|
||||
val visibleStatus = remember {
|
||||
derivedStateOf {
|
||||
when (val raw = rawStatus) {
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> null
|
||||
is UpdateStatus.Downloaded -> raw
|
||||
is UpdateStatus.Available, is UpdateStatus.Downloading ->
|
||||
if (UpdateDismissalPreferences.isDismissed(raw, dismissedKeyState.value)) {
|
||||
null
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Async Play listener (DOWNLOADED / DOWNLOADING) feeds rawStatus directly.
|
||||
DisposableEffect(source) {
|
||||
source.onStatusChanged = { newStatus -> rawStatus = newStatus }
|
||||
onDispose { source.dispose() }
|
||||
}
|
||||
|
||||
// Throttled check: once on first composition, then on every ON_RESUME. The
|
||||
// throttle (maybeCheck) no-ops unless the auto-check interval has elapsed,
|
||||
// so the initial check + resume checks don't double-hit Play/GitHub.
|
||||
val sourceState = rememberUpdatedState(source)
|
||||
LaunchedEffect(source) {
|
||||
maybeCheck(appContext, sourceState.value) { rawStatus = it }
|
||||
}
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
scope.launch { maybeCheck(appContext, sourceState.value) { rawStatus = it } }
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
return remember(source) {
|
||||
UpdateAvailabilityHandle(
|
||||
visibleStatus = visibleStatus,
|
||||
onUpdateClick = {
|
||||
val current = visibleStatus.value
|
||||
if (current is UpdateStatus.Downloaded) {
|
||||
source.completeUpdate()
|
||||
} else {
|
||||
source.startUpdate(activityState.value)
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
visibleStatus.value?.dismissKey?.let { key ->
|
||||
scope.launch { UpdateDismissalPreferences.dismiss(appContext, key) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire a check iff the throttle window has elapsed; records the time on success. */
|
||||
private suspend fun maybeCheck(
|
||||
context: Context,
|
||||
source: UpdateAvailabilitySource,
|
||||
onResult: (UpdateStatus) -> Unit,
|
||||
) {
|
||||
val last = UpdateDismissalPreferences.lastCheckAtMs(context).first()
|
||||
val overdue = (System.currentTimeMillis() - last) > AUTO_CHECK_INTERVAL_MS
|
||||
if (!overdue) return
|
||||
val result = source.check()
|
||||
onResult(result)
|
||||
UpdateDismissalPreferences.markChecked(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up the ContextWrapper chain to the hosting Activity. Needed by the Play
|
||||
* FLEXIBLE flow (`startUpdateFlow` hosts its consent dialog on an Activity);
|
||||
* `LocalContext.current` inside a ComponentActivity is the activity, but the
|
||||
* direct cast can silently fail behind theme/inflater wrappers. Mirrors
|
||||
* `BridgeScreen.findActivity()`.
|
||||
*/
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a [UpdateStatus] versionLabel for display. The sideload track passes
|
||||
* a raw semver string (e.g. "1.3.0") → "v1.3.0"; the Play track passes a
|
||||
* generic phrase (e.g. "A new version", since Play exposes only a versionCode)
|
||||
* → shown verbatim. Heuristic: prefix "v" only when the label begins with a
|
||||
* digit.
|
||||
*/
|
||||
private fun displayVersion(label: String): String =
|
||||
if (label.firstOrNull()?.isDigit() == true) "v$label" else label
|
||||
|
||||
/**
|
||||
* Shared, dismissable Material 3 update banner — serves both flavors.
|
||||
*
|
||||
* Visual treatment matches [ConnectionStatusToast]: an opaque Surface
|
||||
* (tinted container composited over the theme surface so content doesn't bleed
|
||||
* through), rounded 16dp, shadow elevation, status-bar inset. Render it inside
|
||||
* the host's floating top-overlay Box so it slides over content instead of
|
||||
* resizing it.
|
||||
*
|
||||
* Copy + primary action key off [status]:
|
||||
* - [UpdateStatus.Available] → "Update available" + "Update" (Play flow /
|
||||
* browser) + dismiss (X).
|
||||
* - [UpdateStatus.Downloading] → "Downloading update…" + progress bar, no
|
||||
* action button (Play is working); dismiss still available.
|
||||
* - [UpdateStatus.Downloaded] → "Update ready — restart" + "Restart"
|
||||
* (completeUpdate). No dismiss — finishing the install is the only sane
|
||||
* next step, and Play has already staged the APK.
|
||||
*
|
||||
* [UpdateStatus.UpToDate] / [Unsupported] render nothing (caller should gate
|
||||
* on a non-null visible status, but this guards defensively).
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateAvailableBanner(
|
||||
status: UpdateStatus,
|
||||
onUpdate: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = true,
|
||||
) {
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val containerColor = MaterialTheme.colorScheme.primaryContainer.compositeOver(surface)
|
||||
val contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
val title: String
|
||||
val subtitle: String?
|
||||
val actionLabel: String?
|
||||
val showDismiss: Boolean
|
||||
val downloading = status as? UpdateStatus.Downloading
|
||||
|
||||
when (status) {
|
||||
is UpdateStatus.Available -> {
|
||||
title = "Update available"
|
||||
subtitle = "${displayVersion(status.versionLabel)} is ready to install."
|
||||
actionLabel = "Update"
|
||||
showDismiss = true
|
||||
}
|
||||
is UpdateStatus.Downloading -> {
|
||||
title = "Downloading update…"
|
||||
subtitle = displayVersion(status.versionLabel)
|
||||
actionLabel = null
|
||||
showDismiss = true
|
||||
}
|
||||
is UpdateStatus.Downloaded -> {
|
||||
title = "Update ready — restart"
|
||||
subtitle = "${displayVersion(status.versionLabel)} downloaded. Restart to finish."
|
||||
actionLabel = "Restart"
|
||||
showDismiss = false
|
||||
}
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> return
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 24.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (downloading != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SystemUpdate,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.82f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (actionLabel != null) {
|
||||
Button(
|
||||
onClick = onUpdate,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 14.dp,
|
||||
vertical = 4.dp,
|
||||
),
|
||||
) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
if (showDismiss) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downloading != null && downloading.totalBytes > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = {
|
||||
(downloading.bytesDownloaded.toFloat() /
|
||||
downloading.totalBytes.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
} else if (downloading != null) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -61,7 +60,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -81,7 +82,9 @@ import com.hermesandroid.relay.viewmodel.PermissionDeniedCallout
|
||||
import com.hermesandroid.relay.viewmodel.VoiceHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
|
||||
@@ -608,9 +611,17 @@ private fun VoiceMicButton(
|
||||
val gestureModifier = when (uiState.interactionMode) {
|
||||
InteractionMode.HoldToTalk -> Modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
currentOnHoldPress()
|
||||
waitForUpOrCancellation()
|
||||
// Hold until the finger genuinely lifts. Don't use
|
||||
// waitForUpOrCancellation(): it ends the hold on ANY cancel — a
|
||||
// consumed move event or the finger drifting just off the small
|
||||
// circle — which made the button feel like it released by
|
||||
// accident. Loop until no pointer is still pressed so drift and
|
||||
// minor consumption don't cut the recording short.
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
} while (event.changes.any { it.pressed })
|
||||
currentOnHoldRelease()
|
||||
}
|
||||
}
|
||||
@@ -729,7 +740,10 @@ private fun VoiceSessionPill(
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
|
||||
// Fully opaque panel — the overlay floats over live chat/sphere, so a
|
||||
// translucent surface let the background bleed through and made the
|
||||
// dropdown text hard to read.
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 5.dp,
|
||||
shadowElevation = 7.dp,
|
||||
) {
|
||||
@@ -741,12 +755,29 @@ private fun VoiceSessionPill(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
// Show the active profile's local icon (if set) as the leading
|
||||
// glyph — same circular avatar treatment chat uses in
|
||||
// MessageBubble. Falls back to the equalizer icon when there's
|
||||
// no profile icon; the sphere/pet remains the no-icon fallback.
|
||||
val agentIconPath = LocalAgentIconPath.current
|
||||
if (!agentIconPath.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = File(agentIconPath),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.testTag("voiceOverlayProfileIcon")
|
||||
.size(18.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Voice",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
@@ -857,7 +888,11 @@ private fun VoiceSessionPill(
|
||||
onClick = { onFocusModeChange(!focusMode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(if (focusMode) "Compact" else "Focus")
|
||||
Text(
|
||||
if (focusMode) "Compact" else "Focus",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
@@ -866,13 +901,13 @@ private fun VoiceSessionPill(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Overlay")
|
||||
Text("Overlay", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Exit")
|
||||
Text("Exit", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
// Settings link (4c): exit voice mode before navigating
|
||||
// so the overlay isn't left floating over the Voice
|
||||
@@ -991,7 +1026,8 @@ private fun VoiceControlChip(
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f)
|
||||
// Opaque — translucent chips over the floating overlay were hard to read.
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
@@ -1021,9 +1057,11 @@ private fun StatusPill(
|
||||
modifier = modifier.height(24.dp),
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (emphasized) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f)
|
||||
// Opaque — translucent status bubbles over the floating overlay were
|
||||
// hard to read against the sphere/chat behind them.
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.62f)
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (emphasized) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.components
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -19,20 +20,37 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
|
||||
* the About screen. Parses [whats_new.txt]'s tiny markup — a version line,
|
||||
* blank-separated sections with a plain-text header, `*` bullets with indented
|
||||
* continuation lines — into styled Compose instead of pasting the raw text
|
||||
* (which showed literal `*` and gave headers no emphasis).
|
||||
* the About screen.
|
||||
*
|
||||
* As of the multi-version changelog work, the single source of truth is the
|
||||
* bundled [changelog.json] asset (see [ChangelogStore]). This dialog renders the
|
||||
* *latest* entry; the full version history lives in `ChangelogScreen`, which
|
||||
* reuses [VersionNotesBlock] for per-version rendering so the styling stays in
|
||||
* lockstep.
|
||||
*
|
||||
* For resilience the dialog still falls back to the legacy [whats_new.txt]
|
||||
* tiny-markup format (a version line, blank-separated sections with a plain-text
|
||||
* header, `*` bullets) when the JSON asset is missing or unparseable — that file
|
||||
* is also what `gradle-play-publisher`-adjacent tooling expects to find, so it's
|
||||
* kept current alongside the JSON.
|
||||
*/
|
||||
@Composable
|
||||
fun WhatsNewDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val notes = remember { parseWhatsNew(loadWhatsNew(context)) }
|
||||
// Prefer the structured changelog's latest entry; fall back to the legacy
|
||||
// text asset so a missing/garbled JSON never leaves the dialog empty.
|
||||
val notes = remember {
|
||||
ChangelogStore.loadLatestAsNotes(context)
|
||||
?: parseWhatsNew(loadWhatsNew(context))
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -62,34 +80,7 @@ fun WhatsNewDialog(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
notes.groups.forEachIndexed { index, group ->
|
||||
group.header?.let { header ->
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
|
||||
)
|
||||
}
|
||||
group.bullets.forEach { bullet ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = bullet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VersionNotesBody(notes.groups)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -100,16 +91,167 @@ fun WhatsNewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** One section: an optional header plus its bullets. */
|
||||
private data class WhatsNewGroup(val header: String?, val bullets: List<String>)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Shared rendering — used by both this dialog and ChangelogScreen so a tweak
|
||||
// to bullet/header styling lands in one place.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parsed release notes: the leading version line plus styled sections. */
|
||||
private data class WhatsNewNotes(
|
||||
/** One section: an optional header plus its bullets. */
|
||||
data class WhatsNewGroup(val header: String?, val bullets: List<String>)
|
||||
|
||||
/** Parsed release notes for a single version: the version subtitle + sections. */
|
||||
data class WhatsNewNotes(
|
||||
val version: String?,
|
||||
val groups: List<WhatsNewGroup>,
|
||||
val fallback: String?,
|
||||
val fallback: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders the body of one version's notes — its section headers and bullet
|
||||
* lists — without any surrounding chrome (no title, no scroll container). The
|
||||
* caller owns the [Column] so this can be dropped into a dialog or a screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.VersionNotesBody(groups: List<WhatsNewGroup>) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
group.header?.let { header ->
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
|
||||
)
|
||||
}
|
||||
group.bullets.forEach { bullet ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = bullet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained version block — a version subtitle (version · title · date)
|
||||
* followed by [VersionNotesBody]. Used by ChangelogScreen for each release.
|
||||
*/
|
||||
@Composable
|
||||
fun VersionNotesBlock(entry: ChangelogVersion) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = entry.subtitle(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
VersionNotesBody(entry.toGroups())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Structured changelog model + loader (kotlinx.serialization).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One bullet group within a version: an optional header and its bullets. */
|
||||
@Serializable
|
||||
data class ChangelogSection(
|
||||
val header: String? = null,
|
||||
val bullets: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A single released version's user-facing notes. */
|
||||
@Serializable
|
||||
data class ChangelogVersion(
|
||||
val version: String,
|
||||
val title: String? = null,
|
||||
val date: String? = null,
|
||||
val sections: List<ChangelogSection> = emptyList(),
|
||||
) {
|
||||
/** "v1.2.0 — Make it yours · 2026-06-20" (each token optional but version). */
|
||||
fun subtitle(): String {
|
||||
val head = "v$version"
|
||||
val titlePart = title?.takeIf { it.isNotBlank() }?.let { " — $it" } ?: ""
|
||||
val datePart = date?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""
|
||||
return head + titlePart + datePart
|
||||
}
|
||||
|
||||
fun toGroups(): List<WhatsNewGroup> =
|
||||
sections.map { WhatsNewGroup(it.header?.takeIf { h -> h.isNotBlank() }, it.bullets) }
|
||||
|
||||
/** Adapt this version into the dialog's [WhatsNewNotes] shape. */
|
||||
fun toNotes(): WhatsNewNotes = WhatsNewNotes(
|
||||
version = subtitle(),
|
||||
groups = toGroups(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Top-level shape of [changelog.json]: newest version first. */
|
||||
@Serializable
|
||||
data class Changelog(
|
||||
@SerialName("versions") val versions: List<ChangelogVersion> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses + loads the bundled [changelog.json]. Parsing is a pure function
|
||||
* ([parse]) so it can be unit-tested off-device; only [load] touches the
|
||||
* Android asset stream.
|
||||
*/
|
||||
object ChangelogStore {
|
||||
|
||||
/** Tolerant of upstream additions — unknown keys are ignored. */
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
const val ASSET_NAME: String = "changelog.json"
|
||||
|
||||
/**
|
||||
* Parse raw changelog JSON into the model, preserving file order
|
||||
* (authored newest-first). Returns an empty [Changelog] on blank input or
|
||||
* any deserialization error — callers fall back to the legacy text asset.
|
||||
*/
|
||||
fun parse(raw: String): Changelog {
|
||||
if (raw.isBlank()) return Changelog()
|
||||
return try {
|
||||
json.decodeFromString(Changelog.serializer(), raw)
|
||||
} catch (_: Exception) {
|
||||
Changelog()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read + parse the bundled asset (IO is cheap — a few KB, done once). */
|
||||
fun load(context: Context): Changelog {
|
||||
val raw = try {
|
||||
context.assets.open(ASSET_NAME).bufferedReader().readText()
|
||||
} catch (_: Exception) {
|
||||
return Changelog()
|
||||
}
|
||||
return parse(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest (first) entry rendered into the dialog's notes shape, or null
|
||||
* when the changelog can't be loaded so the caller can fall back to
|
||||
* [whats_new.txt].
|
||||
*/
|
||||
fun loadLatestAsNotes(context: Context): WhatsNewNotes? =
|
||||
load(context).versions.firstOrNull()?.toNotes()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Legacy whats_new.txt fallback parser (kept for resilience + Play tooling).
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify each line of the [whats_new.txt] format:
|
||||
* - line 0 (non-bullet) → version subtitle (`-` upgraded to an em dash),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.ChangelogStore
|
||||
import com.hermesandroid.relay.ui.components.ChangelogVersion
|
||||
import com.hermesandroid.relay.ui.components.VersionNotesBody
|
||||
|
||||
/**
|
||||
* Full release history, sourced from the bundled `changelog.json` (the same
|
||||
* single source the auto post-update [com.hermesandroid.relay.ui.components.WhatsNewDialog]
|
||||
* renders the latest entry from).
|
||||
*
|
||||
* The newest version is expanded by default; every older version is a
|
||||
* collapsible card. Per-version rendering reuses [VersionNotesBody] so the
|
||||
* header/bullet styling matches the auto dialog exactly.
|
||||
*
|
||||
* This is a self-contained screen meant to be hosted inside a full-screen
|
||||
* `Dialog` from Settings — it owns its own [Scaffold] + close affordance and
|
||||
* has no nav dependency.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangelogScreen(
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val versions = remember { ChangelogStore.load(context).versions }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("What's New") },
|
||||
actions = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (versions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No release notes available.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
versions.forEachIndexed { index, entry ->
|
||||
// Latest version is expanded; older ones start collapsed.
|
||||
ChangelogVersionCard(
|
||||
entry = entry,
|
||||
initiallyExpanded = index == 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One version's collapsible card. The header row (version · title · date) is
|
||||
* always visible and toggles the body; the body reuses [VersionNotesBody].
|
||||
*/
|
||||
@Composable
|
||||
private fun ChangelogVersionCard(
|
||||
entry: ChangelogVersion,
|
||||
initiallyExpanded: Boolean,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "v${entry.version}" +
|
||||
(entry.title?.takeIf { it.isNotBlank() }?.let { " — $it" } ?: ""),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
entry.date?.takeIf { it.isNotBlank() }?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) {
|
||||
Icons.Filled.ExpandLess
|
||||
} else {
|
||||
Icons.Filled.ExpandMore
|
||||
},
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
VersionNotesBody(entry.toGroups())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,13 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -37,18 +39,25 @@ import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
// === END PHASE3-safety-rails ===
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
@@ -61,15 +70,20 @@ 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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.components.AgentAvatarFace
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
|
||||
@@ -258,8 +272,18 @@ fun SettingsScreen(
|
||||
// back where they started.
|
||||
var showAgentSheet by remember { mutableStateOf(false) }
|
||||
var showDiagnosticsSheet by remember { mutableStateOf(false) }
|
||||
var showProfileLockDialog by remember { mutableStateOf(false) }
|
||||
// What's New / Changelog — opens the full release history as a
|
||||
// self-contained full-screen Dialog (no nav route). Always available, not
|
||||
// gated on the post-update "seen" state that drives the auto dialog.
|
||||
var showChangelog by remember { mutableStateOf(false) }
|
||||
val diagnosticsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// Profile lock state — this card/dialog is the ONE surface that always
|
||||
// lists every profile, so it does NOT gate on isProfileLocked.
|
||||
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
|
||||
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -333,6 +357,28 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
// ── Profile lock ───────────────────────────────────────────
|
||||
// Pin the app to ONE profile. When locked, the profile pickers
|
||||
// elsewhere collapse to a single locked row; this card's dialog
|
||||
// is the only surface that still lists every profile.
|
||||
val lockedDisplayName: String? = when {
|
||||
!isProfileLocked -> null
|
||||
lockedProfileName == null ||
|
||||
AgentDisplay.isServerDefaultAlias(lockedProfileName) ||
|
||||
lockedProfileName == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY ->
|
||||
"Server default"
|
||||
else ->
|
||||
agentProfiles
|
||||
.firstOrNull { it.name == lockedProfileName }
|
||||
?.let { AgentDisplay.profileDisplayName(it) }
|
||||
?: lockedProfileName!!.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
ProfileLockCard(
|
||||
lockedDisplayName = lockedDisplayName,
|
||||
onClick = { showProfileLockDialog = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
// (The "Active Connection quick-look card" that used to live
|
||||
// here — showing API / Relay / Session status rows with a
|
||||
// clickable shortcut into a separate singular-connection
|
||||
@@ -490,6 +536,14 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.NewReleases,
|
||||
title = "What's New",
|
||||
subtitle = "Release notes and full changelog",
|
||||
onClick = { showChangelog = true },
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Info,
|
||||
title = "About",
|
||||
@@ -546,6 +600,31 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showProfileLockDialog) {
|
||||
ProfileLockDialog(
|
||||
profiles = agentProfiles,
|
||||
isLocked = isProfileLocked,
|
||||
lockedProfileName = lockedProfileName,
|
||||
onLock = { profile -> connectionViewModel.lockProfile(profile) },
|
||||
onUnlock = { connectionViewModel.unlockProfile() },
|
||||
onDismiss = { showProfileLockDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Full-screen changelog. Hosted as a self-contained Dialog (no nav route)
|
||||
// so it stacks over Settings and dismisses back here — mirroring the
|
||||
// showAgentSheet / showDiagnosticsSheet inline-surface pattern above.
|
||||
if (showChangelog) {
|
||||
Dialog(
|
||||
onDismissRequest = { showChangelog = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
ChangelogScreen(onClose = { showChangelog = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -659,6 +738,247 @@ private fun ActiveAgentCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry card for the per-connection profile lock. Subtitle reflects the live
|
||||
* lock state: the locked profile's display name when pinned, or the generic
|
||||
* "Pin the app to one agent profile" prompt when unlocked. Tapping opens
|
||||
* [ProfileLockDialog].
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileLockCard(
|
||||
lockedDisplayName: String?,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = if (lockedDisplayName != null) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Profile lock",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = lockedDisplayName?.let { "Locked to $it" }
|
||||
?: "Pin the app to one agent profile",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one surface that ALWAYS lists every profile (it never gates on the lock
|
||||
* state — it's how the user picks the target or unlocks). A master "Lock to a
|
||||
* profile" toggle reveals a radio list of "Server default" + every advertised
|
||||
* profile. When the stored lock target isn't present in the list, a banner
|
||||
* names the missing profile with an inline Unlock affordance.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProfileLockDialog(
|
||||
profiles: List<Profile>,
|
||||
isLocked: Boolean,
|
||||
lockedProfileName: String?,
|
||||
onLock: (Profile?) -> Unit,
|
||||
onUnlock: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Selectable rows: a synthetic "Server default" sentinel + the advertised
|
||||
// profiles, minus the synthetic "default" alias (folded into Server default).
|
||||
val selectableProfiles = profiles.filterNot { AgentDisplay.isServerDefaultAlias(it.name) }
|
||||
|
||||
// Is the stored lock target Server default (sentinel / "default" alias / null)?
|
||||
val lockedIsServerDefault = lockedProfileName == null ||
|
||||
AgentDisplay.isServerDefaultAlias(lockedProfileName) ||
|
||||
lockedProfileName == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
val lockedProfile = if (lockedIsServerDefault) {
|
||||
null
|
||||
} else {
|
||||
selectableProfiles.firstOrNull { it.name == lockedProfileName }
|
||||
}
|
||||
// Locked to a named profile the server no longer advertises.
|
||||
val lockedProfileMissing = isLocked && !lockedIsServerDefault && lockedProfile == null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Profile lock") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 420.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Pin the app to one agent profile. While locked, the " +
|
||||
"profile pickers elsewhere collapse to a single locked row.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (lockedProfileMissing) {
|
||||
Surface(
|
||||
color = RelayRefresh.Amber.copy(alpha = 0.15f),
|
||||
contentColor = RelayRefresh.Amber,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, RelayRefresh.Amber.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Locked profile '" +
|
||||
(lockedProfileName ?: "") +
|
||||
"' not found on this server.",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onUnlock,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text("Unlock")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Master toggle. Off = unlocked; flipping on locks to the
|
||||
// current effective target (Server default by default, or the
|
||||
// already-stored target when it still resolves).
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Lock to a profile",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = isLocked,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
// Lock to the existing target if it still
|
||||
// resolves, else Server default.
|
||||
onLock(lockedProfile)
|
||||
} else {
|
||||
onUnlock()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
HorizontalDivider()
|
||||
// Server default option.
|
||||
ProfileLockOptionRow(
|
||||
label = "Server default",
|
||||
secondary = "Use this connection's default profile",
|
||||
selected = lockedIsServerDefault,
|
||||
onSelect = { onLock(null) },
|
||||
)
|
||||
selectableProfiles.forEach { profile ->
|
||||
ProfileLockOptionRow(
|
||||
label = AgentDisplay.profileDisplayName(profile)
|
||||
?: profile.name.replaceFirstChar { it.uppercase() },
|
||||
secondary = profile.model.takeIf { it.isNotBlank() },
|
||||
selected = !lockedIsServerDefault &&
|
||||
lockedProfileName == profile.name,
|
||||
onSelect = { onLock(profile) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileLockOptionRow(
|
||||
label: String,
|
||||
secondary: String?,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.selectable(
|
||||
selected = selected,
|
||||
role = Role.RadioButton,
|
||||
onClick = onSelect,
|
||||
)
|
||||
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (secondary != null) {
|
||||
Text(
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SettingsStatusPillModel(
|
||||
val label: String,
|
||||
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
|
||||
|
||||
@@ -57,6 +57,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
@@ -117,6 +118,12 @@ fun VoiceSettingsScreen(
|
||||
*/
|
||||
standardVoiceSignInRouteHint: String? = null,
|
||||
relayVoiceReady: Boolean = false,
|
||||
/**
|
||||
* Active connection id used to namespace per-profile voice prefs so two
|
||||
* connections that expose a same-named profile don't share voice picks.
|
||||
* Passed from RelayApp; null degrades to profile-only namespacing.
|
||||
*/
|
||||
connectionId: String? = null,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
@@ -141,13 +148,12 @@ fun VoiceSettingsScreen(
|
||||
|
||||
// WP-V2/V3: point the screen's prefs repo at the active (connection,
|
||||
// profile) scope so the per-profile engine/route/enhanced toggles read and
|
||||
// write the SAME namespaced keys VoiceViewModel seeds from. RelayApp never
|
||||
// wires a connection id into the voice-prefs scope today (the VM mirrors
|
||||
// ProfileSelectionStore profile-only keying), so we pass a null connection
|
||||
// id and the normalized profile name — matching VoiceViewModel exactly.
|
||||
LaunchedEffect(selectedProfile?.name) {
|
||||
// write the SAME namespaced keys VoiceViewModel seeds from. The connection
|
||||
// id (when supplied by RelayApp) disambiguates two connections that expose
|
||||
// a same-named profile; the normalized profile name matches VoiceViewModel.
|
||||
LaunchedEffect(connectionId, selectedProfile?.name) {
|
||||
prefsRepo.setActiveScope(
|
||||
connectionId = null,
|
||||
connectionId = connectionId,
|
||||
profileName = AgentDisplay.profileRequestName(selectedProfile?.name),
|
||||
)
|
||||
}
|
||||
@@ -415,6 +421,26 @@ private fun VoiceForThisProfileCard(
|
||||
onOpenManage: (() -> Unit)?,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-repair when Relay disappears out from under a Relay-only selection:
|
||||
// a persisted RealtimeAgent engine (Relay-only) or Relay route can't run
|
||||
// without a paired Relay, so fall back to the always-available defaults.
|
||||
LaunchedEffect(relayVoiceReady, currentEngine, currentAudioRoute) {
|
||||
if (!relayVoiceReady) {
|
||||
if (currentEngine == VoiceEngineMode.RealtimeAgent) {
|
||||
prefsRepo.setEngineMode(VoiceEngineMode.HermesVoiceOutput)
|
||||
}
|
||||
val coerced = coerceAudioRoute(
|
||||
engine = VoiceEngineMode.HermesVoiceOutput,
|
||||
route = currentAudioRoute,
|
||||
relayVoiceReady = false,
|
||||
)
|
||||
if (coerced != currentAudioRoute) {
|
||||
prefsRepo.setAudioRoute(coerced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard(title = "Voice for this profile") {
|
||||
Text(
|
||||
text = "Voice engine",
|
||||
@@ -434,13 +460,33 @@ private fun VoiceForThisProfileCard(
|
||||
),
|
||||
).forEach { (engine, copy) ->
|
||||
val (label, detail, experimental) = copy
|
||||
// RealtimeAgent requires a paired Relay; HermesVoiceOutput is always
|
||||
// selectable. The existing warning row below explains the disabled
|
||||
// RealtimeAgent radio.
|
||||
val engineEnabled = engine == VoiceEngineMode.HermesVoiceOutput || relayVoiceReady
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentEngine == engine,
|
||||
enabled = engineEnabled,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setEngineMode(engine) }
|
||||
scope.launch {
|
||||
prefsRepo.setEngineMode(engine)
|
||||
// Switching to HermesVoiceOutput may leave a now
|
||||
// invalid persisted route (e.g. Relay while
|
||||
// unpaired) — coerce it to a reachable one.
|
||||
if (engine == VoiceEngineMode.HermesVoiceOutput) {
|
||||
val coerced = coerceAudioRoute(
|
||||
engine = engine,
|
||||
route = currentAudioRoute,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
)
|
||||
if (coerced != currentAudioRoute) {
|
||||
prefsRepo.setAudioRoute(coerced)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
@@ -449,6 +495,7 @@ private fun VoiceForThisProfileCard(
|
||||
RadioButton(
|
||||
selected = currentEngine == engine,
|
||||
onClick = null,
|
||||
enabled = engineEnabled,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -456,7 +503,15 @@ private fun VoiceForThisProfileCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (engineEnabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
if (experimental) ExperimentalBadge("Experimental")
|
||||
}
|
||||
Text(
|
||||
@@ -535,11 +590,16 @@ private fun VoiceForThisProfileCard(
|
||||
badge = "Optional",
|
||||
),
|
||||
).forEach { option ->
|
||||
// Auto always stays selectable (it self-resolves to whatever's
|
||||
// reachable). Standard/Relay are only selectable when their live
|
||||
// availability probe says so — otherwise the radio is dimmed.
|
||||
val routeEnabled = option.route == VoiceAudioRoute.Auto || option.statusOk
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = currentAudioRoute == option.route,
|
||||
enabled = routeEnabled,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setAudioRoute(option.route) }
|
||||
},
|
||||
@@ -550,6 +610,7 @@ private fun VoiceForThisProfileCard(
|
||||
RadioButton(
|
||||
selected = currentAudioRoute == option.route,
|
||||
onClick = null,
|
||||
enabled = routeEnabled,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -557,7 +618,15 @@ private fun VoiceForThisProfileCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(option.label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
option.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (routeEnabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
option.badge?.let { ExperimentalBadge(it) }
|
||||
}
|
||||
Text(
|
||||
@@ -2286,6 +2355,29 @@ private data class RouteOption(
|
||||
val badge: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure coercion of a persisted [route] to a reachable one for the given
|
||||
* [engine] / [relayVoiceReady] combination. Keeps the engine/route radios from
|
||||
* leaving a stale invalid selection persisted (e.g. a Relay route after Relay
|
||||
* was unpaired). Unit-testable; the composable just applies the result.
|
||||
*
|
||||
* - Engine == RealtimeAgent requires a paired Relay; this helper only governs
|
||||
* the audio route, so when relay isn't ready the route is forced to [Auto]
|
||||
* (the caller separately forces the engine back to HermesVoiceOutput).
|
||||
* - [VoiceAudioRoute.Relay] is only valid when [relayVoiceReady].
|
||||
* - [VoiceAudioRoute.Auto] is always valid (it self-resolves at runtime).
|
||||
* - [VoiceAudioRoute.Standard] is left as-is — its reachability is a live
|
||||
* dashboard probe the UI dims via `statusOk`, not something we can know here.
|
||||
*/
|
||||
internal fun coerceAudioRoute(
|
||||
engine: VoiceEngineMode,
|
||||
route: VoiceAudioRoute,
|
||||
relayVoiceReady: Boolean,
|
||||
): VoiceAudioRoute = when {
|
||||
route == VoiceAudioRoute.Relay && !relayVoiceReady -> VoiceAudioRoute.Auto
|
||||
else -> route
|
||||
}
|
||||
|
||||
private data class VoiceChoice(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
@@ -2687,12 +2779,18 @@ private fun VoiceChoiceDropdown(
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(choice.label)
|
||||
Text(
|
||||
choice.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
choice.detail?.takeIf { it.isNotBlank() }?.let { detail ->
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2809,6 +2907,8 @@ private fun ProviderRow(label: String, value: String) {
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(0.62f),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* Flavor-agnostic "is there a newer version?" abstraction.
|
||||
*
|
||||
* Two implementations exist, one per product flavor, each exporting a
|
||||
* [createUpdateAvailabilitySource] factory with the identical signature +
|
||||
* package (mirroring `voice/VoiceBridgeIntentFactory`'s
|
||||
* `createVoiceBridgeIntentHandler` pattern):
|
||||
*
|
||||
* - **googlePlay** — backs onto Google Play's In-App Update API
|
||||
* (`AppUpdateManager`), FLEXIBLE flow. [check] reports
|
||||
* [UpdateStatus.Available] when Play has a newer build; [startUpdate]
|
||||
* kicks off the in-app download dialog; an `InstallStateUpdatedListener`
|
||||
* flips the state to [UpdateStatus.Downloaded] once the APK is staged, at
|
||||
* which point the banner offers "Restart to finish" → [completeUpdate].
|
||||
*
|
||||
* - **sideload** — wraps the existing GitHub-releases [UpdateChecker]. There
|
||||
* is no in-app download/install on this track, so [startUpdate] opens the
|
||||
* APK/release URL in the browser and the status never reaches
|
||||
* [UpdateStatus.Downloaded].
|
||||
*
|
||||
* The shared `UpdateAvailableBanner` + `rememberUpdateAvailability` entry
|
||||
* point in the UI layer drive both through this one interface.
|
||||
*
|
||||
* Threading: [check] suspends and is expected to run its own IO hop
|
||||
* internally; callers may invoke it from any dispatcher.
|
||||
*/
|
||||
interface UpdateAvailabilitySource {
|
||||
|
||||
/**
|
||||
* Probe for an update. Returns the current [UpdateStatus]. Implementations
|
||||
* MUST swallow their own transport/availability failures and degrade to
|
||||
* [UpdateStatus.UpToDate] (or [UpdateStatus.Unsupported]) rather than
|
||||
* throwing — a flaky network or a Play-less device must never crash the
|
||||
* caller. A transient error maps to [UpdateStatus.UpToDate] so the banner
|
||||
* simply stays hidden until the next check.
|
||||
*/
|
||||
suspend fun check(): UpdateStatus
|
||||
|
||||
/**
|
||||
* Begin the update.
|
||||
*
|
||||
* - googlePlay: launches the Play FLEXIBLE consent + background-download
|
||||
* flow. Needs a foreground [activity] to host Play's dialog. Returns
|
||||
* `true` if the flow was started (or already running), `false` if it
|
||||
* could not be launched (no activity / Play unavailable).
|
||||
* - sideload: opens the APK or release page in the browser. [activity]
|
||||
* may be null; returns `true` if an intent was dispatched.
|
||||
*
|
||||
* Safe to call repeatedly — implementations no-op if a flow is already in
|
||||
* flight.
|
||||
*/
|
||||
fun startUpdate(activity: Activity?): Boolean
|
||||
|
||||
/**
|
||||
* Finish a FLEXIBLE update that has finished downloading (state is
|
||||
* [UpdateStatus.Downloaded]). googlePlay calls `AppUpdateManager.
|
||||
* completeUpdate()` which restarts the app to swap in the new APK.
|
||||
* sideload is a no-op (its install is handled by the system installer
|
||||
* after the browser download).
|
||||
*/
|
||||
fun completeUpdate()
|
||||
|
||||
/**
|
||||
* Optional hook for the host to learn about asynchronous status changes
|
||||
* that happen *outside* a [check] — specifically the Play FLEXIBLE
|
||||
* download completing while the user is in the app. The googlePlay impl
|
||||
* pushes [UpdateStatus.Downloaded] (and download progress as
|
||||
* [UpdateStatus.Downloading]) here via its install-state listener; the
|
||||
* sideload impl never invokes it. Set to null to detach.
|
||||
*/
|
||||
var onStatusChanged: ((UpdateStatus) -> Unit)?
|
||||
|
||||
/**
|
||||
* Release any registered listeners / resources. The host calls this from
|
||||
* a Compose `DisposableEffect` `onDispose`. Idempotent.
|
||||
*/
|
||||
fun dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flavor-agnostic update availability state.
|
||||
*
|
||||
* `versionLabel` is a human-readable string for the banner ("1.3.0" /
|
||||
* "android-v1.3.0"); `versionCode` is the numeric Play versionCode when known
|
||||
* (googlePlay) and null on sideload (GitHub releases are tracked by version
|
||||
* string, not code). [Available] also carries an opaque [openUrl] the sideload
|
||||
* impl uses to route `startUpdate` to the browser; googlePlay leaves it null.
|
||||
*/
|
||||
sealed class UpdateStatus {
|
||||
|
||||
/** No newer version, Play/GitHub unreachable-but-degraded, or not yet checked. */
|
||||
data object UpToDate : UpdateStatus()
|
||||
|
||||
/** This flavor/device can't surface an update at all (e.g. Play services absent). */
|
||||
data object Unsupported : UpdateStatus()
|
||||
|
||||
/** A newer version exists and the user can start the update. */
|
||||
data class Available(
|
||||
val versionLabel: String,
|
||||
val versionCode: Long? = null,
|
||||
/** Browser fallback target for sideload (APK asset or release page). Null on Play. */
|
||||
val openUrl: String? = null,
|
||||
) : UpdateStatus()
|
||||
|
||||
/**
|
||||
* googlePlay FLEXIBLE download in progress. [bytesDownloaded] /
|
||||
* [totalBytes] may be 0 before Play reports sizes; the banner shows an
|
||||
* indeterminate bar until [totalBytes] is positive.
|
||||
*/
|
||||
data class Downloading(
|
||||
val versionLabel: String,
|
||||
val versionCode: Long? = null,
|
||||
val bytesDownloaded: Long = 0,
|
||||
val totalBytes: Long = 0,
|
||||
) : UpdateStatus()
|
||||
|
||||
/**
|
||||
* googlePlay FLEXIBLE update finished downloading and is staged; calling
|
||||
* [UpdateAvailabilitySource.completeUpdate] restarts the app to install.
|
||||
*/
|
||||
data class Downloaded(
|
||||
val versionLabel: String,
|
||||
val versionCode: Long? = null,
|
||||
) : UpdateStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* The dismissal-relevant identity of an available update — the value the
|
||||
* per-version dismiss preference keys on. Play builds key on the numeric
|
||||
* versionCode (monotonic, unambiguous); sideload keys on the version string.
|
||||
* A *newer* identity than the dismissed one re-shows the banner (see
|
||||
* [UpdateDismissalPreferences]).
|
||||
*/
|
||||
val UpdateStatus.dismissKey: String?
|
||||
get() = when (this) {
|
||||
is UpdateStatus.Available -> versionCode?.toString() ?: versionLabel
|
||||
is UpdateStatus.Downloading -> versionCode?.toString() ?: versionLabel
|
||||
is UpdateStatus.Downloaded -> versionCode?.toString() ?: versionLabel
|
||||
UpdateStatus.UpToDate, UpdateStatus.Unsupported -> null
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-version dismissal + check-throttle state for the unified
|
||||
* (flavor-agnostic) update banner.
|
||||
*
|
||||
* Deliberately a NEW store, separate from the legacy sideload-only
|
||||
* [UpdatePreferences] (which keys dismissal by version *string* only and is
|
||||
* still consumed by the existing `UpdateViewModel` + About screen). This one
|
||||
* keys by the abstract [UpdateStatus.dismissKey]:
|
||||
* - googlePlay → numeric versionCode (monotonic, compared as Long)
|
||||
* - sideload → version string (compared with [compareVersions])
|
||||
*
|
||||
* Dismissal is **per-version, not a forever mute**: the banner reappears as
|
||||
* soon as a *strictly newer* version than the dismissed one is offered. See
|
||||
* [isDismissed].
|
||||
*
|
||||
* `lastCheckAtMs` throttles automatic checks so we don't hit Play / GitHub on
|
||||
* every cold start and resume.
|
||||
*/
|
||||
private val Context.updateBannerPrefsStore by
|
||||
preferencesDataStore(name = "hermes_relay_update_banner")
|
||||
|
||||
object UpdateDismissalPreferences {
|
||||
private val KEY_DISMISSED = stringPreferencesKey("dismissed_update_key")
|
||||
private val KEY_LAST_CHECK = longPreferencesKey("last_check_at_ms")
|
||||
|
||||
fun dismissedKey(context: Context): Flow<String?> =
|
||||
context.updateBannerPrefsStore.data.map { prefs: Preferences ->
|
||||
prefs[KEY_DISMISSED]
|
||||
}
|
||||
|
||||
fun lastCheckAtMs(context: Context): Flow<Long> =
|
||||
context.updateBannerPrefsStore.data.map { prefs: Preferences ->
|
||||
prefs[KEY_LAST_CHECK] ?: 0L
|
||||
}
|
||||
|
||||
suspend fun dismiss(context: Context, dismissKey: String) {
|
||||
context.updateBannerPrefsStore.edit { it[KEY_DISMISSED] = dismissKey }
|
||||
}
|
||||
|
||||
suspend fun markChecked(context: Context, atMs: Long = System.currentTimeMillis()) {
|
||||
context.updateBannerPrefsStore.edit { it[KEY_LAST_CHECK] = atMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [available]'s dismiss key has already been dismissed.
|
||||
*
|
||||
* Returns `true` only when [dismissed] is non-null AND [available] is NOT
|
||||
* strictly newer than it. A newer version always re-shows (returns
|
||||
* `false`), which is the whole point of per-version dismissal.
|
||||
*
|
||||
* Pure function (no I/O) so it's unit-testable without DataStore. Both
|
||||
* keys are compared numerically when both parse as Longs (Play
|
||||
* versionCodes), otherwise via the loose [compareVersions] semver
|
||||
* comparator (sideload version strings). A mixed/unparseable pair falls
|
||||
* back to exact-string equality — conservative: only the exact dismissed
|
||||
* key stays hidden.
|
||||
*/
|
||||
fun isDismissed(available: UpdateStatus, dismissed: String?): Boolean {
|
||||
val candidate = available.dismissKey ?: return false
|
||||
if (dismissed.isNullOrBlank()) return false
|
||||
return !isStrictlyNewer(candidate, dismissed)
|
||||
}
|
||||
|
||||
/** True if [candidate] represents a strictly newer version than [reference]. */
|
||||
internal fun isStrictlyNewer(candidate: String, reference: String): Boolean {
|
||||
val c = candidate.toLongOrNull()
|
||||
val r = reference.toLongOrNull()
|
||||
if (c != null && r != null) return c > r
|
||||
// Fall back to semver string compare; if neither parses cleanly that
|
||||
// comparator still yields 0 for equal strings, so exact dupes stay
|
||||
// dismissed and any lexical/semver delta re-shows.
|
||||
return compareVersions(reference, candidate) < 0
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.net.URLEncoder
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
@@ -28,9 +27,13 @@ import kotlin.system.exitProcess
|
||||
* records the crash. We only observe; we never swallow.
|
||||
*
|
||||
* On the next launch [CrashReportGate] reads the pending report and offers the
|
||||
* user a clean copy / "report on GitHub" flow (see [CrashReportDialog]). The
|
||||
* GitHub path pre-fills our `bug_report.yml` issue form so a one-star "it keeps
|
||||
* crashing" review can become an actionable issue with a stack trace attached.
|
||||
* user three GitHub-free-friendly actions (see [CrashReportDialog]): copy the
|
||||
* full report, **share** it via the system sheet (email / chat / notes — the
|
||||
* path for users without a GitHub account and for sideload installs Play vitals
|
||||
* never sees), or open a pre-filled `bug_report.yml` issue. The GitHub path
|
||||
* turns a one-star "it keeps crashing" review into an actionable issue with a
|
||||
* stack trace attached; share/copy cover everyone else. Every outbound path is
|
||||
* user-initiated — nothing is transmitted automatically.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
@@ -38,10 +41,6 @@ object CrashReporter {
|
||||
private const val DIR = "crash"
|
||||
private const val FILE = "last-crash.json"
|
||||
|
||||
/** Public issue tracker — keep in sync with the git remote. */
|
||||
private const val GITHUB_NEW_ISSUE =
|
||||
"https://github.com/Codename-11/hermes-relay/issues/new"
|
||||
|
||||
/**
|
||||
* Cap the stack trace we inline into the GitHub URL. Browsers + GitHub
|
||||
* truncate very long URLs, so we ship the head of the trace in the form and
|
||||
@@ -140,17 +139,11 @@ object CrashReporter {
|
||||
* issue. The body mirrors `bug_report.yml`'s sections in markdown so triage
|
||||
* structure is preserved without depending on the preview path.
|
||||
*/
|
||||
fun buildGithubIssueUrl(report: CrashReport): String {
|
||||
// LinkedHashMap preserves a stable, readable param order.
|
||||
val params = linkedMapOf(
|
||||
"title" to "[Bug]: Crash — ${report.shortTitle()}",
|
||||
"labels" to "bug",
|
||||
"body" to buildIssueBody(report),
|
||||
)
|
||||
return GITHUB_NEW_ISSUE + "?" + params.entries.joinToString("&") { (key, value) ->
|
||||
"$key=" + URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
}
|
||||
fun buildGithubIssueUrl(report: CrashReport): String = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Crash — ${report.shortTitle()}",
|
||||
bodyMarkdown = buildIssueBody(report),
|
||||
labels = "bug",
|
||||
)
|
||||
|
||||
private fun buildIssueBody(report: CrashReport): String {
|
||||
val trace = report.stackTrace.let {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* One implementation of the three user-initiated outbound paths shared by the
|
||||
* crash reporter and the diagnostics detail view: build a pre-filled GitHub
|
||||
* "new issue" URL, hand text to the system share sheet, and copy text to the
|
||||
* clipboard.
|
||||
*
|
||||
* Nothing here transmits automatically — every path is triggered by an explicit
|
||||
* user tap, and the share/clipboard paths never leave the device until the user
|
||||
* chooses a destination. Both [CrashReporter]/[com.hermesandroid.relay.ui.components.CrashReportGate]
|
||||
* and the diagnostics detail dialog call these so the GitHub URL shape, share
|
||||
* intent, and clipboard label stay identical across the app.
|
||||
*/
|
||||
object IssueReport {
|
||||
|
||||
/** Public issue tracker — keep in sync with the git remote. */
|
||||
private const val GITHUB_NEW_ISSUE =
|
||||
"https://github.com/Codename-11/hermes-relay/issues/new"
|
||||
|
||||
/** Clipboard label used for every report copy in the app. */
|
||||
const val CLIP_LABEL = "Hermes-Relay report"
|
||||
|
||||
/**
|
||||
* Build a pre-filled GitHub "new issue" URL from a generic title/body/labels
|
||||
* triple.
|
||||
*
|
||||
* Uses the **stable** classic `title` + `body` + `labels` query params, NOT
|
||||
* issue-form field-`id` prefilling (`template=...&<id>=...`). The latter is a
|
||||
* GitHub public-preview feature that was observed to silently not apply (only
|
||||
* `title` carried), which is unacceptable for a reporter that fires on devices
|
||||
* we can't retry from. `blank_issues_enabled: true` in
|
||||
* `.github/ISSUE_TEMPLATE/config.yml` guarantees `?body=` opens a prefilled
|
||||
* issue.
|
||||
*
|
||||
* @param labels comma-separated GitHub labels (e.g. "bug"); omitted from the
|
||||
* query when blank.
|
||||
*/
|
||||
fun buildGithubIssueUrl(
|
||||
title: String,
|
||||
bodyMarkdown: String,
|
||||
labels: String = "bug",
|
||||
): String {
|
||||
// LinkedHashMap preserves a stable, readable param order.
|
||||
val params = linkedMapOf("title" to title)
|
||||
if (labels.isNotBlank()) params["labels"] = labels
|
||||
params["body"] = bodyMarkdown
|
||||
return GITHUB_NEW_ISSUE + "?" + params.entries.joinToString("&") { (key, value) ->
|
||||
"$key=" + URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy [text] to the system clipboard under the shared report label. */
|
||||
fun copyToClipboard(context: Context, text: String, label: String = CLIP_LABEL) {
|
||||
runCatching {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer [text] to the system share sheet (email, chat, notes, Drive…). The
|
||||
* user picks the destination, so nothing leaves the device until they choose
|
||||
* to send it — same privacy posture as [copyToClipboard]. Returns false if no
|
||||
* app can handle a plain-text share so callers can fall back to clipboard.
|
||||
*/
|
||||
fun share(context: Context, subject: String, text: String, chooserTitle: String = "Share report"): Boolean =
|
||||
runCatching {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(send, chooserTitle).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Open [url] in the user's browser. Returns false if nothing can handle it. */
|
||||
fun openUrl(context: Context, url: String): Boolean = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
@@ -133,14 +135,51 @@ private fun classifyIoMessage(msg: String, context: String?): HumanError? {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the caller's [classifyError] context tag to a diagnostics category so the
|
||||
* recorded error lands under the right surface in the activity log. Defaults to
|
||||
* [DiagnosticCategory.Api] for unknown/null contexts.
|
||||
*/
|
||||
private fun categoryForContext(context: String?): DiagnosticCategory = when (context) {
|
||||
"transcribe", "synthesize", "voice_config", "record" -> DiagnosticCategory.Voice
|
||||
"pair" -> DiagnosticCategory.Auth
|
||||
"save_and_test", "media_fetch" -> DiagnosticCategory.Relay
|
||||
"send_message" -> DiagnosticCategory.Api
|
||||
else -> DiagnosticCategory.Api
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an arbitrary Throwable into a user-facing [HumanError].
|
||||
*
|
||||
* **Side effect:** every classified error is also recorded to [DiagnosticsLog]
|
||||
* (Error severity, clean title + full redacted stacktrace) so the diagnostics
|
||||
* activity log captures it with zero call-site churn. The return value and all
|
||||
* existing copy are unchanged. The flow is one-way — [DiagnosticsLog.recordError]
|
||||
* never re-enters the classifier — so there is no recursion. A null throwable
|
||||
* produces a fallback but is NOT recorded (nothing actually failed).
|
||||
*
|
||||
* @param context short tag that shapes the title ("transcribe", "synthesize",
|
||||
* "voice_config", "record", "pair", "save_and_test",
|
||||
* "media_fetch", "send_message", or null for generic)
|
||||
*/
|
||||
fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
val human = classifyErrorInternal(t, context)
|
||||
if (t != null) {
|
||||
// Record after classification so the clean title and the raw trace both
|
||||
// reach the log. Defensive: never let logging turn a handled error fatal.
|
||||
runCatching {
|
||||
DiagnosticsLog.recordError(
|
||||
category = categoryForContext(context),
|
||||
title = human.title,
|
||||
detail = human.body,
|
||||
throwable = t,
|
||||
)
|
||||
}
|
||||
}
|
||||
return human
|
||||
}
|
||||
|
||||
private fun classifyErrorInternal(t: Throwable?, context: String?): HumanError {
|
||||
if (t == null) return nullFallback(context)
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
@@ -1052,6 +1052,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
fun selectProfile(profile: Profile?) = profileController.selectProfile(profile)
|
||||
|
||||
// --- Profile lock (per-connection pin to one profile) ------------------
|
||||
//
|
||||
// When set, the profile pickers/switchers across the app collapse to a
|
||||
// single locked state; only the dedicated Settings control still lists
|
||||
// every profile (to change the lock target or unlock). `lockedProfileName`
|
||||
// is the raw stored token (the SERVER_DEFAULT_PROFILE_KEY sentinel means
|
||||
// "locked to Server default"); `isProfileLocked` is the convenience boolean.
|
||||
|
||||
val lockedProfileName: StateFlow<String?> get() = profileController.lockedProfileName
|
||||
|
||||
val isProfileLocked: StateFlow<Boolean> get() = profileController.isProfileLocked
|
||||
|
||||
/** Lock the active connection to [profile] (null = Server default). */
|
||||
fun lockProfile(profile: Profile?) {
|
||||
viewModelScope.launch { profileController.lockProfile(profile) }
|
||||
}
|
||||
|
||||
/** Remove the active connection's profile lock. */
|
||||
fun unlockProfile() {
|
||||
viewModelScope.launch { profileController.unlockProfile() }
|
||||
}
|
||||
|
||||
// --- Paired devices list (GET /sessions) -------------------------------
|
||||
//
|
||||
// Loaded on-demand from PairedDevicesScreen. Owned by [pairingController];
|
||||
@@ -1714,9 +1736,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionHandoffClearJob = viewModelScope.launch {
|
||||
delay(
|
||||
when {
|
||||
// Live, in-progress handoff: keep the spinner up as a backstop
|
||||
// until it resolves to success/error (which then clears fast).
|
||||
active -> 30_000L
|
||||
// Resolved states auto-dismiss within 5s — anything longer
|
||||
// reads as a stuck overlay.
|
||||
success -> 5_000L
|
||||
else -> 12_000L
|
||||
else -> 5_000L
|
||||
}
|
||||
)
|
||||
if (_connectionHandoffStatus.value?.updatedAtMs == now) {
|
||||
@@ -2360,6 +2386,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ProfileSelectionStore is a separate DataStore file from
|
||||
// ConnectionStore's EncryptedSharedPrefs.
|
||||
profileController.profileSelectionStore.clear(connectionId)
|
||||
profileController.profileLockStore.clear(connectionId)
|
||||
profileController.profileSessionStore.clearConnection(connectionId)
|
||||
profileController.profileDisplayAliasStore.clearConnection(connectionId)
|
||||
profileController.profileIconStore.clearConnection(connectionId)
|
||||
@@ -2760,6 +2787,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
connectionStore.removeConnection(duplicate.id)
|
||||
profileController.profileSelectionStore.clear(duplicate.id)
|
||||
profileController.profileLockStore.clear(duplicate.id)
|
||||
profileController.profileSessionStore.clearConnection(duplicate.id)
|
||||
}
|
||||
|
||||
@@ -3211,6 +3239,26 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve when the per-connection profile lock changes — locking,
|
||||
// unlocking, and the lock flow repointing after a connection switch all
|
||||
// funnel through here so the active profile always reflects the lock
|
||||
// target (or holds null + a banner when it's missing). resolvePending
|
||||
// is lock-aware, so on unlock it falls back to the persisted selection.
|
||||
viewModelScope.launch {
|
||||
profileController.lockedProfileName.collect {
|
||||
if (profileController.resolvePendingProfileFrom(
|
||||
profileController.agentProfiles.value,
|
||||
)
|
||||
) {
|
||||
profileController.refreshLastSessionForProfile(
|
||||
activeConnectionId.value,
|
||||
profileController.selectedProfile.value?.name,
|
||||
)
|
||||
rebuildChatApiClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-start restore timing: the gateway probe is async, so the first
|
||||
// refreshLastSessionForProfile at connection-activate can run while
|
||||
// availability is still Unknown — [activeSessionTransport] defers, leaving
|
||||
@@ -5157,6 +5205,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
|
||||
@@ -339,6 +339,18 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val AUDIO_COMPLETION_MAX_SLEEP_MS = 750L
|
||||
private const val OUTPUT_AUDIO_ACTIVE_THRESHOLD = 0.012f
|
||||
|
||||
/**
|
||||
* W3 spoken-status throttle. On long / tool-heavy realtime runs the
|
||||
* agent emits many spoken status lines ("Searching.", "Still working.")
|
||||
* which becomes chatty. Independent of the per-key [spokenStatusKeys]
|
||||
* dedupe: this caps BOTH the spoken cadence (no two spoken status lines
|
||||
* within [MIN_SPOKEN_STATUS_GAP_MS]) and the per-turn spoken count
|
||||
* ([MAX_SPOKEN_STATUS_PER_TURN]). Suppressed lines still update the UI
|
||||
* + diagnostics — only the TTS enqueue is skipped.
|
||||
*/
|
||||
private const val MIN_SPOKEN_STATUS_GAP_MS = 22_000L
|
||||
private const val MAX_SPOKEN_STATUS_PER_TURN = 3
|
||||
|
||||
/**
|
||||
* Resume watchdog window (B4). After a hard barge-in interrupt, the
|
||||
* VoiceViewModel listens for user-speech silence for this many ms
|
||||
@@ -435,6 +447,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var responseText = StringBuilder()
|
||||
private var inputTranscript = StringBuilder()
|
||||
private val spokenStatusKeys = mutableSetOf<String>()
|
||||
// W3 spoken-status throttle (per turn). Reset alongside spokenStatusKeys at
|
||||
// every turn start/reset. See [shouldSpeakStatusNow] for the decision.
|
||||
private var lastSpokenStatusAtMs: Long = 0L
|
||||
private var spokenStatusCount: Int = 0
|
||||
private var voiceRelayPreflight: (suspend () -> Result<Unit>)? = null
|
||||
|
||||
// === PHASE3-voice-intents: voice→bridge intent routing ===
|
||||
@@ -542,6 +558,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var firstFrameWatchdogJob: Job? = null
|
||||
private var continuousLoopArmed: Boolean = false
|
||||
private var lastRealtimeAudioDeltaAtMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Set true when the user interrupts a realtime turn ([interruptSpeaking]).
|
||||
* The persistent realtime socket stays open by design, so audio deltas
|
||||
* already in flight can still arrive after we stop the player and would
|
||||
* re-create the AudioTrack — making "Stop" feel like it didn't work. While
|
||||
* suppressed, [handleRealtimeVoiceEvent] drops audio writes. Cleared when
|
||||
* the next turn is actually sent ([submitRealtimeTurn] / [runRealtimeAgentTurn]).
|
||||
*/
|
||||
@Volatile
|
||||
private var realtimeAudioSuppressed: Boolean = false
|
||||
private var listeningStartedAtMs: Long = 0L
|
||||
// 2026-04-18: silence-based auto-stop watchdog. Runs for the duration
|
||||
// of a Listening turn in TapToTalk / Continuous modes when the user has
|
||||
@@ -1526,6 +1553,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
"Interrupting speech pipeline",
|
||||
)
|
||||
cancelRealtimeAgentTurn("interrupt")
|
||||
// Drop realtime audio deltas still in flight on the open socket so a
|
||||
// stopped turn's tail can't re-create the player and resume playback.
|
||||
realtimeAudioSuppressed = true
|
||||
// B4: tear down the barge-in listener immediately so we don't
|
||||
// double-trigger on the ducking watchdog or emit another
|
||||
// bargeInDetected while the resume watchdog is deliberating.
|
||||
@@ -2193,6 +2223,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
persistentOpen: Boolean = false,
|
||||
) {
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
streamObserverJob?.cancel()
|
||||
streamObserverJob = null
|
||||
drainQueuedLocalTts()
|
||||
@@ -2227,6 +2259,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = StringBuilder()
|
||||
inputTranscript = StringBuilder()
|
||||
spokenStatusKeys.clear()
|
||||
lastSpokenStatusAtMs = 0L
|
||||
spokenStatusCount = 0
|
||||
rtUserText = userText
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
@@ -2257,6 +2291,30 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
if (speak && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
// W3: per-turn throttle independent of the per-key dedupe above.
|
||||
// Suppress the TTS enqueue (UI state + diagnostics already
|
||||
// applied) when spoken status is too frequent or has hit the
|
||||
// per-turn cap, so long / tool-heavy runs don't over-narrate.
|
||||
val now = System.currentTimeMillis()
|
||||
if (!shouldSpeakStatusNow(
|
||||
now = now,
|
||||
lastSpokenAtMs = lastSpokenStatusAtMs,
|
||||
count = spokenStatusCount,
|
||||
gapMs = MIN_SPOKEN_STATUS_GAP_MS,
|
||||
maxCount = MAX_SPOKEN_STATUS_PER_TURN,
|
||||
)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime status TTS suppressed (throttle) key=$key " +
|
||||
"count=$spokenStatusCount sinceLastMs=${
|
||||
if (lastSpokenStatusAtMs > 0L) now - lastSpokenStatusAtMs else -1L
|
||||
} line=$line",
|
||||
)
|
||||
return
|
||||
}
|
||||
lastSpokenStatusAtMs = now
|
||||
spokenStatusCount += 1
|
||||
val remainingProviderAudioMs = if (speakEvenAfterProviderAudio && audioSeen.get()) {
|
||||
300L
|
||||
} else {
|
||||
@@ -2265,6 +2323,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime status TTS queued key=$key speak=$speak " +
|
||||
"count=$spokenStatusCount " +
|
||||
"afterProviderAudio=${audioSeen.get()} delayMs=$remainingProviderAudioMs line=$line",
|
||||
)
|
||||
if (remainingProviderAudioMs > 0L) {
|
||||
@@ -2612,6 +2671,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private fun submitRealtimeTurn(chatVm: ChatViewModel, inputPcm: ByteArray, inputSampleRate: Int) {
|
||||
val channel = realtimeTurnChannel ?: return
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
drainQueuedLocalTts()
|
||||
try { player?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
firstFrameWatchdogJob?.cancel(); firstFrameWatchdogJob = null
|
||||
@@ -2623,6 +2684,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = StringBuilder()
|
||||
inputTranscript = StringBuilder()
|
||||
spokenStatusKeys.clear()
|
||||
lastSpokenStatusAtMs = 0L
|
||||
spokenStatusCount = 0
|
||||
rtUserText = ""
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
@@ -2930,7 +2993,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null &&
|
||||
voiceAudioClient?.route == VoiceAudioRoute.Relay
|
||||
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
|
||||
// Auto -> Relay when relay is ready, so in `auto` mode with relay
|
||||
// paired the override-capable relay path engages. Reading the raw
|
||||
// `route` would stay "Auto" and silently drop the chosen override.
|
||||
voiceAudioClient?.effectiveRoute == VoiceAudioRoute.Relay
|
||||
|
||||
private fun drainSentences() {
|
||||
while (true) {
|
||||
@@ -3129,6 +3196,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
decision.firstAudioMs?.let { ms ->
|
||||
Log.i(TAG, "Realtime watchdog: first audio reached speaker after ${ms}ms")
|
||||
// Flip the waveform's output gate the instant playback truly
|
||||
// starts, even if no further audio-delta byte event arrives
|
||||
// to drive handleRealtimeVoiceEvent — the unfold then lands
|
||||
// exactly at the first audible frame.
|
||||
if (_uiState.value.state == VoiceState.Speaking) {
|
||||
_uiState.update { st -> st.copy(outputAudioActive = true) }
|
||||
}
|
||||
}
|
||||
if (decision.reportStuck) {
|
||||
reportedStuck = true
|
||||
@@ -3159,6 +3233,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
bargeInStarted: AtomicBoolean,
|
||||
) {
|
||||
if (!event.isAudioDelta) return
|
||||
// After an interrupt, ignore the cancelled turn's in-flight audio tail
|
||||
// until the next turn is sent (which clears the flag). Otherwise these
|
||||
// late deltas re-create the player and playback resumes after "Stop".
|
||||
if (realtimeAudioSuppressed) return
|
||||
val encoded = event.audioBase64 ?: return
|
||||
val audio = try {
|
||||
Base64.getDecoder().decode(encoded)
|
||||
@@ -3185,11 +3263,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
startRealtimePlaybackWatchdog()
|
||||
}
|
||||
if (_uiState.value.state == VoiceState.Speaking) {
|
||||
// Keep feeding the visual envelope from the decoded level so the
|
||||
// waveform stays smooth, but gate `outputAudioActive` on REAL
|
||||
// playback start (head-move / head-synced amplitude) rather than on
|
||||
// the decoded RMS, which leads the audible frame by the player's
|
||||
// start prebuffer. Mirrors the basic-TTS Visualizer gating.
|
||||
speakEnvelope = applyEnvelope(speakEnvelope, level)
|
||||
val snap = pcmPlayer.snapshot()
|
||||
val playbackActive = shouldMarkRealtimeOutputActive(
|
||||
headFrames = snap.headFrames,
|
||||
playbackAmplitude = pcmPlayer.playbackAmplitude(),
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
amplitude = speakEnvelope,
|
||||
outputAudioActive = it.outputAudioActive || level > OUTPUT_AUDIO_ACTIVE_THRESHOLD,
|
||||
outputAudioActive = it.outputAudioActive || playbackActive,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5085,6 +5173,50 @@ internal fun evaluateFirstFrameWatchdog(
|
||||
return FirstFrameWatchdogDecision(null, reportStuck = stuck, keepWatching = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure gate for the realtime waveform's `outputAudioActive` flag (W3).
|
||||
*
|
||||
* The decoded-PCM RMS [level] arrives before the audio is actually audible —
|
||||
* [RealtimePcmPlayer] holds a start prebuffer, so the first few deltas decode
|
||||
* (level > 0) while the AudioTrack head is still parked at frame 0. Gating
|
||||
* `outputAudioActive` on [level] therefore unfolds the UI too early.
|
||||
*
|
||||
* Instead we gate on a playback-synced signal: the playback head has actually
|
||||
* moved ([headFrames] > 0) and/or the head-tracked [playbackAmplitude] is
|
||||
* non-zero. This mirrors the basic-TTS path, where the Visualizer only reports
|
||||
* amplitude once ExoPlayer is genuinely producing audio.
|
||||
*
|
||||
* Returns true once playback has really started so the caller may flip
|
||||
* `outputAudioActive` true (it is monotonic per turn — the caller ORs it).
|
||||
*/
|
||||
internal fun shouldMarkRealtimeOutputActive(
|
||||
headFrames: Int,
|
||||
playbackAmplitude: Float,
|
||||
): Boolean = headFrames > 0 || playbackAmplitude > 0f
|
||||
|
||||
/**
|
||||
* Pure decision for the W3 spoken-status throttle. Returns true when a spoken
|
||||
* status line should actually be enqueued for TTS right now, given the time of
|
||||
* the last spoken status ([lastSpokenAtMs], 0 = none yet this turn), how many
|
||||
* have already been spoken this turn ([count]), the minimum inter-status gap
|
||||
* ([gapMs]), and the per-turn cap ([maxCount]).
|
||||
*
|
||||
* Independent of the per-key dedupe — this caps cadence + volume so long /
|
||||
* tool-heavy runs don't narrate every step.
|
||||
*/
|
||||
internal fun shouldSpeakStatusNow(
|
||||
now: Long,
|
||||
lastSpokenAtMs: Long,
|
||||
count: Int,
|
||||
gapMs: Long,
|
||||
maxCount: Int,
|
||||
): Boolean {
|
||||
if (count >= maxCount) return false
|
||||
// First spoken status of the turn (lastSpokenAtMs == 0) is always allowed.
|
||||
if (lastSpokenAtMs > 0L && now - lastSpokenAtMs < gapMs) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drain cross-check (#3): estimate vs. real hardware head position. */
|
||||
internal data class DrainDrift(
|
||||
val actualRemainingMs: Long,
|
||||
|
||||
+134
@@ -7,6 +7,7 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfileDisplayAliasStore
|
||||
import com.hermesandroid.relay.data.ProfileIconStore
|
||||
import com.hermesandroid.relay.data.ProfileLockStore
|
||||
import com.hermesandroid.relay.data.ProfileSelectionStore
|
||||
import com.hermesandroid.relay.data.ProfileSessionStore
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
@@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -113,6 +115,13 @@ class ProfileController(
|
||||
val profileSessionStore: ProfileSessionStore = ProfileSessionStore(context)
|
||||
val profileDisplayAliasStore: ProfileDisplayAliasStore = ProfileDisplayAliasStore(context)
|
||||
|
||||
/**
|
||||
* Per-connection "profile lock" persistence (twin of [profileSelectionStore],
|
||||
* sharing the same DataStore). Public so the ViewModel's connection-lifecycle
|
||||
* orchestrators can clear it alongside the selection store.
|
||||
*/
|
||||
val profileLockStore: ProfileLockStore = ProfileLockStore(context)
|
||||
|
||||
val profileDisplayAlias: StateFlow<String?> = combine(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
@@ -126,6 +135,28 @@ class ProfileController(
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* The active connection's stored profile-lock target, or `null` when the
|
||||
* connection is unlocked. The value is the raw stored token: the sentinel
|
||||
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY] means "locked to Server default",
|
||||
* any other string is a profile name. Built by flatMapLatest on the active
|
||||
* connection id exactly like [profileDisplayAlias] so it repoints cleanly
|
||||
* across connection switches.
|
||||
*/
|
||||
val lockedProfileName: StateFlow<String?> = activeConnectionId
|
||||
.flatMapLatest { connectionId ->
|
||||
if (connectionId == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
profileLockStore.lockedProfileFlow(connectionId)
|
||||
}
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/** True when the active connection is pinned to a single profile. */
|
||||
val isProfileLocked: StateFlow<Boolean> = lockedProfileName
|
||||
.map { it != null }
|
||||
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
val profileIconStore: ProfileIconStore = ProfileIconStore(context)
|
||||
|
||||
/** The active profile's local agent-icon path (twin of [profileDisplayAlias]). */
|
||||
@@ -233,13 +264,46 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored lock-token for a (possibly null) profile. Server default —
|
||||
* including the synthetic "default" alias — maps to
|
||||
* [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY]; everything else to its name.
|
||||
* Mirrors [AgentDisplay.profileSessionKey] so the lock token and the
|
||||
* session/selection key for the same profile always agree.
|
||||
*/
|
||||
private fun lockTokenFor(profile: Profile?): String =
|
||||
AgentDisplay.profileSessionKey(profile?.name)
|
||||
|
||||
/**
|
||||
* Set (or clear, with `null`) the active profile pick. Writes through
|
||||
* to [profileSelectionStore] for the currently-active connection so
|
||||
* the selection survives process death and connection switches.
|
||||
*
|
||||
* When the connection is **locked**, a request for a profile other than
|
||||
* the locked target is ignored (the pickers are gated, but this guards the
|
||||
* programmatic paths too — e.g. voice/card dispatch). Re-selecting the
|
||||
* locked target is allowed (it's a no-op against current state anyway).
|
||||
*/
|
||||
fun selectProfile(profile: Profile?) {
|
||||
val normalizedProfile = AgentDisplay.normalizeSelection(profile)
|
||||
val locked = lockedProfileName.value
|
||||
if (locked != null && lockTokenFor(normalizedProfile) != locked) {
|
||||
// Pinned to a different profile — refuse the switch. Never silently
|
||||
// coerce to the locked target here; the resolution path already
|
||||
// holds the selection on the locked target (or null if it's gone).
|
||||
return
|
||||
}
|
||||
applyProfileSelection(normalizedProfile)
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual selection write — runs the full profile-switch machinery
|
||||
* (fresh draft via [setLastSessionId], pending-state stamp, persist,
|
||||
* chat-API rebuild, last-session restore). Bypasses the lock gate so
|
||||
* [lockProfile] can force-select the new locked target even mid-relock;
|
||||
* [selectProfile] is the gated public entry point.
|
||||
*/
|
||||
private fun applyProfileSelection(normalizedProfile: Profile?) {
|
||||
_selectedProfile.value = normalizedProfile
|
||||
setLastSessionId(null)
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
@@ -257,6 +321,15 @@ class ProfileController(
|
||||
if (_pendingSelectedProfileConnectionId.value != connectionId) {
|
||||
return false
|
||||
}
|
||||
// When the connection is locked, the lock target — NOT the pending or
|
||||
// persisted selection — decides the active profile. The sentinel means
|
||||
// Server default (selection null); any other token resolves against the
|
||||
// current list. If the locked profile isn't (yet/anymore) advertised we
|
||||
// HOLD on null so the Settings banner can explain it — never fall back.
|
||||
val locked = lockedProfileName.value
|
||||
if (locked != null) {
|
||||
return resolveLockedProfileFrom(locked, list)
|
||||
}
|
||||
val current = _selectedProfile.value
|
||||
if (current != null) {
|
||||
if (AgentDisplay.isServerDefaultAlias(current.name)) {
|
||||
@@ -290,6 +363,67 @@ class ProfileController(
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active profile against the lock [token] (already known to be
|
||||
* non-null by the caller). Returns true when the selection changed.
|
||||
*
|
||||
* - sentinel → Server default → selection null.
|
||||
* - a name present in [list] → select that profile.
|
||||
* - a name absent from [list] → HOLD on null (the locked profile is gone
|
||||
* or hasn't been advertised yet); the pending name is kept so a banner
|
||||
* can name it and so a later list arrival can recover it.
|
||||
*/
|
||||
private fun resolveLockedProfileFrom(token: String, list: List<Profile>): Boolean {
|
||||
if (AgentDisplay.isServerDefaultAlias(token) ||
|
||||
token == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
) {
|
||||
_pendingSelectedProfileName.value = null
|
||||
val changed = _selectedProfile.value != null
|
||||
_selectedProfile.value = null
|
||||
return changed
|
||||
}
|
||||
val resolved = list.firstOrNull { it.name == token }
|
||||
if (resolved != null) {
|
||||
val changed = _selectedProfile.value != resolved
|
||||
_selectedProfile.value = resolved
|
||||
_pendingSelectedProfileName.value = resolved.name
|
||||
return changed
|
||||
}
|
||||
// Locked profile not present — hold on null, keep the pending name so the
|
||||
// banner can name it and a later arrival can recover the lock.
|
||||
_pendingSelectedProfileName.value = token
|
||||
val changed = _selectedProfile.value != null
|
||||
_selectedProfile.value = null
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the active connection to [profile]. A `null` argument locks to
|
||||
* **Server default** (stored as the [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY]
|
||||
* sentinel so it's distinct from "unlocked"). Persists the lock, then forces
|
||||
* the selection to the locked target via the normal [selectProfile] path so
|
||||
* the existing profile-switch machinery (fresh draft, gateway hot-swap, chat
|
||||
* API rebuild) runs. Locking to the already-selected profile is effectively
|
||||
* a no-op for the selection but still records the lock.
|
||||
*/
|
||||
suspend fun lockProfile(profile: Profile?) {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
val normalizedProfile = AgentDisplay.normalizeSelection(profile)
|
||||
val token = lockTokenFor(normalizedProfile)
|
||||
// Persist the lock first, then force-select via the un-gated body so the
|
||||
// switch lands even when re-locking from a different target (the
|
||||
// lockedProfileName StateFlow may still hold the previous token until the
|
||||
// DataStore emission propagates).
|
||||
profileLockStore.setLockedProfile(connectionId, token)
|
||||
applyProfileSelection(normalizedProfile)
|
||||
}
|
||||
|
||||
/** Remove the lock for the active connection (back to free profile choice). */
|
||||
suspend fun unlockProfile() {
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
profileLockStore.setLockedProfile(connectionId, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which transport's session slot to restore right now — or `null` when the
|
||||
* decision is still pending (the gateway probe hasn't landed). A manual
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* === update (sideload flavor): factory ===
|
||||
*
|
||||
* Backs [UpdateAvailabilitySource] onto the existing GitHub-releases
|
||||
* [UpdateChecker]. There is no in-app download/install on the sideload track:
|
||||
* [startUpdate] opens the APK asset (or release page) in the browser and the
|
||||
* status never advances past [UpdateStatus.Available]. Mirrors
|
||||
* `voice/VoiceBridgeIntentFactory`'s flavor-split factory pattern — same
|
||||
* function signature + package as the googlePlay flavor.
|
||||
*/
|
||||
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
|
||||
GitHubUpdateAvailabilitySource(context.applicationContext)
|
||||
|
||||
private const val TAG = "SideloadUpdate"
|
||||
|
||||
private class GitHubUpdateAvailabilitySource(
|
||||
private val appContext: Context,
|
||||
) : UpdateAvailabilitySource {
|
||||
|
||||
// Sideload reports updates synchronously from [check]; there is no async
|
||||
// listener, so this is never invoked. Present for interface parity.
|
||||
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
|
||||
|
||||
/** Resolved on [check] so [startUpdate] can route to the right URL. */
|
||||
@Volatile private var pending: UpdateStatus.Available? = null
|
||||
|
||||
override suspend fun check(): UpdateStatus {
|
||||
return when (val result = UpdateChecker.check()) {
|
||||
is UpdateCheckResult.Available -> {
|
||||
val upd = result.update
|
||||
val status = UpdateStatus.Available(
|
||||
// Raw version string — doubles as the per-version dismiss
|
||||
// key (versionCode is null on this track), so it must stay
|
||||
// parseable by compareVersions. The banner formats display.
|
||||
versionLabel = upd.latestVersion,
|
||||
versionCode = null, // GitHub releases tracked by version string, not code
|
||||
openUrl = upd.apkUrl ?: upd.releasePageUrl,
|
||||
)
|
||||
pending = status
|
||||
status
|
||||
}
|
||||
// Errors degrade to UpToDate — the banner just stays hidden, the
|
||||
// About-screen "Check for updates" row still surfaces the error.
|
||||
UpdateCheckResult.Idle,
|
||||
UpdateCheckResult.Checking,
|
||||
UpdateCheckResult.UpToDate,
|
||||
is UpdateCheckResult.Error -> {
|
||||
pending = null
|
||||
UpdateStatus.UpToDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdate(activity: Activity?): Boolean {
|
||||
val target = pending?.openUrl ?: return false
|
||||
return try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, target.toUri())
|
||||
.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
(activity ?: appContext).startActivity(intent)
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startUpdate (browser) failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** No staged install on sideload — the system installer handles the APK. */
|
||||
override fun completeUpdate() = Unit
|
||||
|
||||
override fun dispose() {
|
||||
onStatusChanged = null
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
|
||||
// === END update (sideload) ===
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [ProfileLockStore].
|
||||
*
|
||||
* These exercise the store's **logic** (per-connection key naming, the
|
||||
* null→remove unlock contract, the Server-default sentinel passthrough, and
|
||||
* per-connection isolation) against an in-memory [DataStore] rather than a
|
||||
* filesystem-backed [androidx.datastore.preferences.core.PreferenceDataStoreFactory].
|
||||
*
|
||||
* Why in-memory: a file-backed DataStore performs an atomic write-tmp-then-rename
|
||||
* on every `edit`, and on Windows that rename fails ("Unable to rename … multiple
|
||||
* instances of DataStore") when a prior test method's DataStore coroutine hasn't
|
||||
* released the file handle yet (scope cancellation is async). The in-memory
|
||||
* [DataStore] removes the OS dependency entirely — `edit { }`, `data.map { }`,
|
||||
* `remove`, and `clear` all behave identically, and persistence-to-disk is
|
||||
* DataStore's contract, not [ProfileLockStore]'s.
|
||||
*/
|
||||
class ProfileLockStoreTest {
|
||||
|
||||
private lateinit var store: ProfileLockStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
store = ProfileLockStore(InMemoryPreferencesDataStore())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unset_connection_emitsNull() = runBlocking {
|
||||
// Fresh store — every connection id reads as null (unlocked) until set.
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
assertNull(store.lockedProfileFlow("conn-unknown").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setName_then_get_roundTrips() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
assertEquals("mizu", store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setServerDefaultSentinel_roundTrips() = runBlocking {
|
||||
// Locked-to-Server-default is stored as the sentinel and must read back
|
||||
// verbatim — it is NOT null (that would mean "unlocked").
|
||||
store.setLockedProfile("conn-1", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
|
||||
val value = store.lockedProfileFlow("conn-1").first()
|
||||
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, value)
|
||||
// Belt-and-suspenders: the sentinel must be distinguishable from null.
|
||||
assertEquals("__server_default__", value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setNull_unlocks_removesTheKey() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
assertEquals("mizu", store.lockedProfileFlow("conn-1").first())
|
||||
|
||||
// Writing null removes the key — read path emits null (unlocked).
|
||||
store.setLockedProfile("conn-1", null)
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setNull_afterSentinel_unlocks() = runBlocking {
|
||||
// Going from "locked to Server default" back to "unlocked" must clear the
|
||||
// sentinel, not leave it stuck.
|
||||
store.setLockedProfile("conn-1", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
store.lockedProfileFlow("conn-1").first(),
|
||||
)
|
||||
|
||||
store.setLockedProfile("conn-1", null)
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overwrite_replacesPriorValue() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
store.setLockedProfile("conn-1", "coder")
|
||||
assertEquals("coder", store.lockedProfileFlow("conn-1").first())
|
||||
|
||||
// Name → sentinel and back, to prove neither sticks.
|
||||
store.setLockedProfile("conn-1", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
store.lockedProfileFlow("conn-1").first(),
|
||||
)
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
assertEquals("mizu", store.lockedProfileFlow("conn-1").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perConnectionKeys_areIndependent() = runBlocking {
|
||||
// A lock pinned on one server must not leak onto another. Mix names and
|
||||
// the sentinel across connections.
|
||||
store.setLockedProfile("conn-A", "alpha")
|
||||
store.setLockedProfile("conn-B", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
store.setLockedProfile("conn-C", "gamma")
|
||||
|
||||
assertEquals("alpha", store.lockedProfileFlow("conn-A").first())
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
store.lockedProfileFlow("conn-B").first(),
|
||||
)
|
||||
assertEquals("gamma", store.lockedProfileFlow("conn-C").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clear_removesOnlyTheGivenConnection() = runBlocking {
|
||||
store.setLockedProfile("conn-1", "mizu")
|
||||
store.setLockedProfile("conn-2", "coder")
|
||||
|
||||
store.clear("conn-1")
|
||||
|
||||
assertNull(store.lockedProfileFlow("conn-1").first())
|
||||
assertEquals("coder", store.lockedProfileFlow("conn-2").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAll_wipesEveryConnection() = runBlocking {
|
||||
store.setLockedProfile("conn-A", "alpha")
|
||||
store.setLockedProfile("conn-B", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
store.setLockedProfile("conn-C", "gamma")
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertNull(store.lockedProfileFlow("conn-A").first())
|
||||
assertNull(store.lockedProfileFlow("conn-B").first())
|
||||
assertNull(store.lockedProfileFlow("conn-C").first())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory [DataStore] of [Preferences] for unit tests — no filesystem,
|
||||
* so no atomic-rename / single-instance contention. [updateData] applies the
|
||||
* transform to the current snapshot and publishes it; [data] replays the latest
|
||||
* value to every collector (so `.first()` after a write sees the update).
|
||||
*/
|
||||
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(emptyPreferences())
|
||||
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-JVM tests for the bundled-changelog parser ([ChangelogStore.parse]) and
|
||||
* the [ChangelogVersion] view helpers. No Android dependency — only the
|
||||
* kotlinx.serialization decode path and string formatting are exercised, so the
|
||||
* Android asset stream ([ChangelogStore.load]) is intentionally out of scope.
|
||||
*/
|
||||
class ChangelogParserTest {
|
||||
|
||||
@Test
|
||||
fun parsesVersionsInFileOrder() {
|
||||
val raw = """
|
||||
{
|
||||
"versions": [
|
||||
{"version": "1.2.0", "title": "Latest", "date": "2026-06-20",
|
||||
"sections": [{"header": "New", "bullets": ["a", "b"]}]},
|
||||
{"version": "1.1.0", "title": "Older", "date": "2026-06-16",
|
||||
"sections": [{"header": "Fixed", "bullets": ["c"]}]}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val changelog = ChangelogStore.parse(raw)
|
||||
|
||||
assertEquals(2, changelog.versions.size)
|
||||
// File order is authored newest-first and must be preserved verbatim.
|
||||
assertEquals("1.2.0", changelog.versions[0].version)
|
||||
assertEquals("1.1.0", changelog.versions[1].version)
|
||||
assertEquals("Latest", changelog.versions[0].title)
|
||||
assertEquals(listOf("a", "b"), changelog.versions[0].sections.first().bullets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankInputYieldsEmptyChangelog() {
|
||||
assertTrue(ChangelogStore.parse("").versions.isEmpty())
|
||||
assertTrue(ChangelogStore.parse(" \n ").versions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedJsonFallsBackToEmptyInsteadOfThrowing() {
|
||||
// The dialog falls back to whats_new.txt when this returns empty, so a
|
||||
// garbled asset must never crash the parse.
|
||||
assertTrue(ChangelogStore.parse("{ this is not json").versions.isEmpty())
|
||||
assertTrue(ChangelogStore.parse("[]").versions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresUnknownTopLevelAndSectionKeys() {
|
||||
// Future authored fields (e.g. a "summary") must not break older apps.
|
||||
val raw = """
|
||||
{
|
||||
"schema": 2,
|
||||
"versions": [
|
||||
{"version": "1.0.0", "summary": "ignored",
|
||||
"sections": [{"header": "H", "bullets": ["x"], "icon": "star"}]}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val changelog = ChangelogStore.parse(raw)
|
||||
|
||||
assertEquals("1.0.0", changelog.versions.single().version)
|
||||
assertEquals(listOf("x"), changelog.versions.single().sections.single().bullets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionalFieldsDefaultGracefully() {
|
||||
// Only `version` is required; title/date/sections may be absent.
|
||||
val raw = """{"versions": [{"version": "0.9.0"}]}"""
|
||||
|
||||
val entry = ChangelogStore.parse(raw).versions.single()
|
||||
|
||||
assertNull(entry.title)
|
||||
assertNull(entry.date)
|
||||
assertTrue(entry.sections.isEmpty())
|
||||
assertTrue(entry.toGroups().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitleJoinsVersionTitleAndDate() {
|
||||
val entry = ChangelogVersion(
|
||||
version = "1.2.0",
|
||||
title = "Make it yours",
|
||||
date = "2026-06-20",
|
||||
)
|
||||
assertEquals("v1.2.0 — Make it yours · 2026-06-20", entry.subtitle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitleOmitsMissingTokens() {
|
||||
assertEquals("v1.2.0", ChangelogVersion(version = "1.2.0").subtitle())
|
||||
assertEquals(
|
||||
"v1.2.0 — Title",
|
||||
ChangelogVersion(version = "1.2.0", title = "Title").subtitle(),
|
||||
)
|
||||
assertEquals(
|
||||
"v1.2.0 · 2026-06-20",
|
||||
ChangelogVersion(version = "1.2.0", date = "2026-06-20").subtitle(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toGroupsDropsBlankHeaders() {
|
||||
val entry = ChangelogVersion(
|
||||
version = "1.0.0",
|
||||
sections = listOf(
|
||||
ChangelogSection(header = " ", bullets = listOf("a")),
|
||||
ChangelogSection(header = "Real", bullets = listOf("b")),
|
||||
),
|
||||
)
|
||||
|
||||
val groups = entry.toGroups()
|
||||
|
||||
assertNull("blank header should normalize to null", groups[0].header)
|
||||
assertEquals("Real", groups[1].header)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toNotesUsesSubtitleAsVersionLine() {
|
||||
val notes = ChangelogVersion(
|
||||
version = "1.2.0",
|
||||
title = "Make it yours",
|
||||
date = "2026-06-20",
|
||||
sections = listOf(ChangelogSection(header = "New", bullets = listOf("a"))),
|
||||
).toNotes()
|
||||
|
||||
assertEquals("v1.2.0 — Make it yours · 2026-06-20", notes.version)
|
||||
assertEquals(1, notes.groups.size)
|
||||
assertEquals("New", notes.groups.single().header)
|
||||
assertEquals(listOf("a"), notes.groups.single().bullets)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the top-level [coerceAudioRoute] helper in
|
||||
* `VoiceSettingsScreen.kt`.
|
||||
*
|
||||
* Contract (see the helper's KDoc):
|
||||
* - [VoiceAudioRoute.Relay] with `relayVoiceReady == false` coerces to
|
||||
* [VoiceAudioRoute.Auto] (a stale Relay pick after Relay was unpaired must
|
||||
* not stay persisted).
|
||||
* - Every other (engine, route, ready) combination passes the route through
|
||||
* unchanged — the engine argument never influences the audio-route result.
|
||||
*/
|
||||
class CoerceAudioRouteTest {
|
||||
|
||||
// --- Relay + not ready → Auto -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun relayWhenNotReady_coercesToAuto_hermesEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Auto,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.HermesVoiceOutput,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayWhenNotReady_coercesToAuto_realtimeEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Auto,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.RealtimeAgent,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Relay + ready → Relay (unchanged) ----------------------------------
|
||||
|
||||
@Test
|
||||
fun relayWhenReady_passesThrough_hermesEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Relay,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.HermesVoiceOutput,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayWhenReady_passesThrough_realtimeEngine() {
|
||||
assertEquals(
|
||||
VoiceAudioRoute.Relay,
|
||||
coerceAudioRoute(
|
||||
engine = VoiceEngineMode.RealtimeAgent,
|
||||
route = VoiceAudioRoute.Relay,
|
||||
relayVoiceReady = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Standard always passes through, regardless of readiness ------------
|
||||
|
||||
@Test
|
||||
fun standard_passesThrough_whenRelayNotReady() {
|
||||
for (engine in VoiceEngineMode.values()) {
|
||||
assertEquals(
|
||||
"Standard must never be coerced (engine=$engine, ready=false)",
|
||||
VoiceAudioRoute.Standard,
|
||||
coerceAudioRoute(engine, VoiceAudioRoute.Standard, relayVoiceReady = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standard_passesThrough_whenRelayReady() {
|
||||
for (engine in VoiceEngineMode.values()) {
|
||||
assertEquals(
|
||||
"Standard must never be coerced (engine=$engine, ready=true)",
|
||||
VoiceAudioRoute.Standard,
|
||||
coerceAudioRoute(engine, VoiceAudioRoute.Standard, relayVoiceReady = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto always passes through (it self-resolves at runtime) -----------
|
||||
|
||||
@Test
|
||||
fun auto_passesThrough_regardlessOfReadiness() {
|
||||
for (engine in VoiceEngineMode.values()) {
|
||||
for (ready in listOf(true, false)) {
|
||||
assertEquals(
|
||||
"Auto is always valid (engine=$engine, ready=$ready)",
|
||||
VoiceAudioRoute.Auto,
|
||||
coerceAudioRoute(engine, VoiceAudioRoute.Auto, relayVoiceReady = ready),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-logic coverage for the unified update banner's per-version dismissal +
|
||||
* the dismiss-key derivation. No DataStore / Android involved — exercises the
|
||||
* exact decision `rememberUpdateAvailability` makes to hide/show the banner.
|
||||
*/
|
||||
class UpdateDismissalTest {
|
||||
|
||||
// ── dismissKey derivation ─────────────────────────────────────────────
|
||||
|
||||
@Test fun `dismissKey prefers numeric versionCode when present (Play)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertEquals("17", s.dismissKey)
|
||||
}
|
||||
|
||||
@Test fun `dismissKey falls back to versionLabel when no code (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = null)
|
||||
assertEquals("1.3.0", s.dismissKey)
|
||||
}
|
||||
|
||||
@Test fun `dismissKey is null for non-actionable statuses`() {
|
||||
assertNull(UpdateStatus.UpToDate.dismissKey)
|
||||
assertNull(UpdateStatus.Unsupported.dismissKey)
|
||||
}
|
||||
|
||||
@Test fun `dismissKey covers Downloading and Downloaded`() {
|
||||
assertEquals("9", UpdateStatus.Downloading("1.1.0", 9L).dismissKey)
|
||||
assertEquals("1.2.0", UpdateStatus.Downloaded("1.2.0", null).dismissKey)
|
||||
}
|
||||
|
||||
// ── per-version dismissal: never dismissed when nothing stored ────────
|
||||
|
||||
@Test fun `not dismissed when no dismissed key stored`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = null))
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = ""))
|
||||
}
|
||||
|
||||
@Test fun `non-actionable status is never dismissed`() {
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(UpdateStatus.UpToDate, "17"))
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(UpdateStatus.Unsupported, "17"))
|
||||
}
|
||||
|
||||
// ── per-version dismissal: Play (numeric versionCode) ─────────────────
|
||||
|
||||
@Test fun `same versionCode stays dismissed (Play)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "17"))
|
||||
}
|
||||
|
||||
@Test fun `older offer than dismissed stays hidden (Play)`() {
|
||||
// Edge case: an older code than the one already dismissed should not
|
||||
// re-nag — only a strictly newer one re-shows.
|
||||
val s = UpdateStatus.Available(versionLabel = "1.2.0", versionCode = 16L)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "17"))
|
||||
}
|
||||
|
||||
@Test fun `newer versionCode re-shows the banner (Play)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.4.0", versionCode = 18L)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = "17"))
|
||||
}
|
||||
|
||||
// ── per-version dismissal: sideload (version string) ──────────────────
|
||||
|
||||
@Test fun `same version string stays dismissed (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.3.0", versionCode = null)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `newer version string re-shows the banner (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.4.0", versionCode = null)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(s, dismissed = "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `older version string stays hidden (sideload)`() {
|
||||
val s = UpdateStatus.Available(versionLabel = "1.2.0", versionCode = null)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(s, dismissed = "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `Downloaded status respects per-version dismissal logic too`() {
|
||||
// (The UI never suppresses Downloaded, but the pure predicate is
|
||||
// consistent: a dismissed-then-downloaded same version reads dismissed.)
|
||||
val downloaded = UpdateStatus.Downloaded(versionLabel = "1.3.0", versionCode = 17L)
|
||||
assertTrue(UpdateDismissalPreferences.isDismissed(downloaded, dismissed = "17"))
|
||||
val newer = UpdateStatus.Downloaded(versionLabel = "1.4.0", versionCode = 18L)
|
||||
assertFalse(UpdateDismissalPreferences.isDismissed(newer, dismissed = "17"))
|
||||
}
|
||||
|
||||
// ── isStrictlyNewer direct coverage ───────────────────────────────────
|
||||
|
||||
@Test fun `isStrictlyNewer numeric`() {
|
||||
assertTrue(UpdateDismissalPreferences.isStrictlyNewer("18", "17"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("17", "17"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("16", "17"))
|
||||
}
|
||||
|
||||
@Test fun `isStrictlyNewer semver string`() {
|
||||
assertTrue(UpdateDismissalPreferences.isStrictlyNewer("1.4.0", "1.3.0"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("1.3.0", "1.3.0"))
|
||||
assertFalse(UpdateDismissalPreferences.isStrictlyNewer("1.2.0", "1.3.0"))
|
||||
}
|
||||
|
||||
@Test fun `isStrictlyNewer mixed-parse falls back to string semver`() {
|
||||
// One numeric, one not → both routed through compareVersions, which
|
||||
// tokenizes leading digits. "abc" → 0, so "1.0.0" is newer.
|
||||
assertTrue(UpdateDismissalPreferences.isStrictlyNewer("1.0.0", "abc"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.io.IOException
|
||||
import java.net.URLDecoder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Covers the two new shared pieces:
|
||||
* - [IssueReport.buildGithubIssueUrl] produces a stable, properly-encoded URL.
|
||||
* - [classifyError] records the classified failure into [DiagnosticsLog] as a
|
||||
* side effect (Error severity, clean title, redacted full stacktrace).
|
||||
*
|
||||
* Both run on pure JVM — no Android framework / Robolectric needed, since
|
||||
* [IssueReport.buildGithubIssueUrl], the classifier, and the log are all plain
|
||||
* Kotlin/Java.
|
||||
*/
|
||||
class IssueReportAndDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun buildGithubIssueUrlEncodesTitleBodyAndLabels() {
|
||||
val url = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Crash — NullPointerException",
|
||||
bodyMarkdown = "line one\nline two & more",
|
||||
labels = "bug",
|
||||
)
|
||||
|
||||
assertTrue(url.startsWith("https://github.com/Codename-11/hermes-relay/issues/new?"))
|
||||
// Stable param order: title, labels, body.
|
||||
assertTrue(url.indexOf("title=") < url.indexOf("labels="))
|
||||
assertTrue(url.indexOf("labels=") < url.indexOf("body="))
|
||||
// Spaces encoded as %20 (not '+'), so the URL works in a browser bar.
|
||||
assertFalse(url.contains("+"))
|
||||
assertTrue(url.contains("%20"))
|
||||
|
||||
val body = url.substringAfter("body=")
|
||||
assertEquals("line one\nline two & more", URLDecoder.decode(body, "UTF-8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildGithubIssueUrlOmitsBlankLabels() {
|
||||
val url = IssueReport.buildGithubIssueUrl(
|
||||
title = "t",
|
||||
bodyMarkdown = "b",
|
||||
labels = "",
|
||||
)
|
||||
assertFalse(url.contains("labels="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyErrorRecordsAnErrorEntryWithCleanTitleAndTrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
val human = classifyError(
|
||||
IOException("List sessions unauthorized - check your API key"),
|
||||
context = "send_message",
|
||||
)
|
||||
|
||||
val entry = DiagnosticsLog.recent().single()
|
||||
assertEquals(DiagnosticSeverity.Error, entry.severity)
|
||||
assertEquals(DiagnosticCategory.Api, entry.category)
|
||||
// Clean human title is what lands in the list — not the raw exception text.
|
||||
assertEquals(human.title, entry.title)
|
||||
assertEquals("API key rejected", entry.title)
|
||||
// Full stacktrace is captured for the detail page.
|
||||
assertNotNull(entry.stacktrace)
|
||||
assertTrue(entry.stacktrace!!.contains("IOException"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyErrorMapsVoiceContextToVoiceCategory() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
classifyError(IOException("404 not found"), context = "voice_config")
|
||||
|
||||
val entry = DiagnosticsLog.recent().single()
|
||||
assertEquals(DiagnosticCategory.Voice, entry.category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifyErrorWithNullThrowableRecordsNothing() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
classifyError(null, context = "send_message")
|
||||
|
||||
assertTrue(DiagnosticsLog.recent().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordErrorRedactsSecretsInTheStacktrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
DiagnosticsLog.recordError(
|
||||
category = DiagnosticCategory.Relay,
|
||||
title = "Boom",
|
||||
throwable = RuntimeException("rejected token=super-secret-token-value end"),
|
||||
)
|
||||
|
||||
val entry = DiagnosticsLog.recent().single()
|
||||
assertNotNull(entry.stacktrace)
|
||||
assertFalse(entry.stacktrace!!.contains("super-secret-token-value"))
|
||||
assertTrue(entry.stacktrace!!.contains("token=[hidden]"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the two pure decision helpers extracted from
|
||||
* [VoiceViewModel]: [shouldSpeakStatusNow] (the W3 spoken-status throttle) and
|
||||
* [shouldMarkRealtimeOutputActive] (the playback-synced "output is live" gate).
|
||||
*
|
||||
* Both are top-level `internal` and side-effect free, so they need no
|
||||
* ViewModel / Android scaffolding.
|
||||
*/
|
||||
class VoiceStatusGatesTest {
|
||||
|
||||
// --- shouldSpeakStatusNow ----------------------------------------------
|
||||
//
|
||||
// Contract (from the helper + its KDoc):
|
||||
// if (count >= maxCount) return false // cap wins first
|
||||
// if (lastSpokenAtMs > 0 && now - lastSpokenAtMs < gapMs) return false
|
||||
// else return true
|
||||
//
|
||||
// i.e. lastSpokenAtMs == 0 ("none yet this turn") skips the gap check, but
|
||||
// the over-count cap is still enforced ahead of it.
|
||||
|
||||
@Test
|
||||
fun firstOfTurn_isAllowed() {
|
||||
// lastSpokenAtMs == 0 → no prior status this turn → always allowed
|
||||
// (count under cap).
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 0L,
|
||||
lastSpokenAtMs = 0L,
|
||||
count = 0,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstOfTurn_allowed_evenWhenNowIsLargeAndGapWide() {
|
||||
// The gap check is skipped entirely when lastSpokenAtMs == 0, so a huge
|
||||
// `now` against a wide gap is irrelevant.
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 1_000_000L,
|
||||
lastSpokenAtMs = 0L,
|
||||
count = 2,
|
||||
gapMs = 10_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withinGap_isSuppressed() {
|
||||
// now - lastSpokenAtMs = 5_000 - 2_000 = 3_000 < 4_000 → suppress.
|
||||
assertFalse(
|
||||
shouldSpeakStatusNow(
|
||||
now = 5_000L,
|
||||
lastSpokenAtMs = 2_000L,
|
||||
count = 1,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overCount_isSuppressed_evenFirstOfTurn() {
|
||||
// count >= maxCount short-circuits to false BEFORE the gap/first checks,
|
||||
// so even lastSpokenAtMs == 0 cannot rescue an over-cap status.
|
||||
assertFalse(
|
||||
shouldSpeakStatusNow(
|
||||
now = 0L,
|
||||
lastSpokenAtMs = 0L,
|
||||
count = 6,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overCount_isSuppressed_pastGap() {
|
||||
// Well past the gap, but at the cap → still suppressed.
|
||||
assertFalse(
|
||||
shouldSpeakStatusNow(
|
||||
now = 100_000L,
|
||||
lastSpokenAtMs = 1_000L,
|
||||
count = 7,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastGap_underCount_isAllowed() {
|
||||
// now - lastSpokenAtMs = 10_000 - 2_000 = 8_000 >= 4_000 gap, count < cap.
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 10_000L,
|
||||
lastSpokenAtMs = 2_000L,
|
||||
count = 2,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactlyAtGap_isAllowed() {
|
||||
// now - lastSpokenAtMs == gapMs (4_000 == 4_000). The suppression
|
||||
// predicate is strict `<`, so being exactly at the gap is NOT suppressed.
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 6_000L,
|
||||
lastSpokenAtMs = 2_000L,
|
||||
count = 1,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun justBelowCount_isAllowed_pastGap() {
|
||||
// count == maxCount - 1 is the last allowed slot (cap check is `>=`).
|
||||
assertTrue(
|
||||
shouldSpeakStatusNow(
|
||||
now = 10_000L,
|
||||
lastSpokenAtMs = 1_000L,
|
||||
count = 5,
|
||||
gapMs = 4_000L,
|
||||
maxCount = 6,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- shouldMarkRealtimeOutputActive ------------------------------------
|
||||
//
|
||||
// Contract: true once headFrames > 0 OR playbackAmplitude > 0f; false only
|
||||
// at the cold (0, 0f) origin.
|
||||
|
||||
@Test
|
||||
fun coldOrigin_isInactive() {
|
||||
assertFalse(shouldMarkRealtimeOutputActive(headFrames = 0, playbackAmplitude = 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headFramesMoved_isActive() {
|
||||
assertTrue(shouldMarkRealtimeOutputActive(headFrames = 1, playbackAmplitude = 0f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun amplitudePresent_isActive() {
|
||||
assertTrue(shouldMarkRealtimeOutputActive(headFrames = 0, playbackAmplitude = 0.01f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothPresent_isActive() {
|
||||
assertTrue(shouldMarkRealtimeOutputActive(headFrames = 1, playbackAmplitude = 0.5f))
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Profile-**lock** behavior of [ProfileController].
|
||||
*
|
||||
* The controller's five persistence stores ([ProfileSelectionStore],
|
||||
* [ProfileSessionStore], [ProfileDisplayAliasStore], [ProfileLockStore],
|
||||
* [ProfileIconStore]) are built from a [Context], so this runs under
|
||||
* Robolectric (same seam as `ConnectionManagerRouteTest`) with
|
||||
* [RuntimeEnvironment.getApplication]. All other collaborators are injected as
|
||||
* lambdas/flows and are either no-ops or capturing stubs.
|
||||
*
|
||||
* Timing note: [ProfileController.lockedProfileName] is a `stateIn(...Eagerly)`
|
||||
* projection of the `ProfileLockStore` DataStore flow, so it lags a write by an
|
||||
* async hop. The controller's own [scope] therefore uses a REAL dispatcher
|
||||
* (Dispatchers.IO) — a StandardTestDispatcher would never let the DataStore
|
||||
* actor or the stateIn collectors run — and the tests `await { ... }` the lock /
|
||||
* selection StateFlows rather than reading them synchronously after a write.
|
||||
*
|
||||
* Scope of coverage: the lock semantics described on [ProfileController]:
|
||||
* - [ProfileController.lockProfile] persists the lock token + force-selects.
|
||||
* - [ProfileController.lockProfile] (null) locks to Server default.
|
||||
* - [ProfileController.selectProfile] is a no-op for a non-locked target while
|
||||
* locked, but allowed for the locked target.
|
||||
* - [ProfileController.resolvePendingProfileFrom] under a lock resolves to the
|
||||
* locked target when present and HOLDS (selection null) when it is absent,
|
||||
* then recovers on a later list arrival.
|
||||
* - [ProfileController.unlockProfile] clears the lock and re-enables selection.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ProfileControllerLockTest {
|
||||
|
||||
private val connectionId = "conn-lock-test"
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var authManager: AuthManager
|
||||
private lateinit var authManagerFlow: MutableStateFlow<AuthManager>
|
||||
private lateinit var activeConnectionId: MutableStateFlow<String?>
|
||||
private lateinit var controller: ProfileController
|
||||
|
||||
private val lastSessionIds = mutableListOf<String?>()
|
||||
|
||||
private val mizu = Profile(name = "mizu", model = "model-a")
|
||||
private val coder = Profile(name = "coder", model = "model-b")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Relay-advertised profile list — empty; tests drive the list explicitly
|
||||
// through resolvePendingProfileFrom(list).
|
||||
authManager = mockk(relaxed = true)
|
||||
every { authManager.agentProfiles } returns MutableStateFlow<List<Profile>>(emptyList()).asStateFlow()
|
||||
authManagerFlow = MutableStateFlow(authManager)
|
||||
|
||||
activeConnectionId = MutableStateFlow<String?>(connectionId)
|
||||
|
||||
controller = ProfileController(
|
||||
context = context,
|
||||
scope = scope,
|
||||
authManagerFlow = authManagerFlow,
|
||||
activeConnectionId = activeConnectionId,
|
||||
activeDashboardUrlProvider = { null },
|
||||
dashboardClientFactory = { _, _ -> mockk<DashboardApiClient>(relaxed = true) },
|
||||
// Non-"auto" so activeSessionTransport() resolves deterministically
|
||||
// (no gateway probe gating) — keeps refreshLastSessionForProfile from
|
||||
// bailing early on Unknown.
|
||||
streamingEndpointProvider = { "completions" },
|
||||
gatewayAvailabilityProvider = { GatewayAvailability.Ready },
|
||||
setLastSessionId = { lastSessionIds += it },
|
||||
legacyDefaultSessionId = { null },
|
||||
rebuildChatApiClient = { },
|
||||
)
|
||||
|
||||
// Guarantee a clean lock slot — the underlying "profile_selections"
|
||||
// DataStore is name-scoped and could carry residual state across runs in
|
||||
// the same JVM.
|
||||
runBlocking { controller.profileLockStore.clear(connectionId) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
// Start each run from clean lock state — the DataStore file is shared by
|
||||
// the app-internal store name across tests in the same JVM.
|
||||
runBlocking { controller.profileLockStore.clear(connectionId) }
|
||||
}
|
||||
|
||||
// --- await helpers ------------------------------------------------------
|
||||
|
||||
private fun <T> awaitFlow(flow: StateFlow<T>, predicate: (T) -> Boolean): T =
|
||||
runBlocking {
|
||||
withTimeout(5_000) { flow.first { predicate(it) } }
|
||||
}
|
||||
|
||||
private fun awaitLocked(token: String?) =
|
||||
awaitFlow(controller.lockedProfileName) { it == token }
|
||||
|
||||
private fun awaitSelected(name: String?) =
|
||||
awaitFlow(controller.selectedProfile) { it?.name == name }
|
||||
|
||||
// --- lockProfile(profile) -----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun lockProfile_persistsTokenAndForceSelects() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
|
||||
// Lock token == the profile name; selection forced to the locked target.
|
||||
assertEquals("mizu", awaitLocked("mizu"))
|
||||
assertEquals(mizu, awaitSelected("mizu"))
|
||||
assertTrue("should report locked", awaitFlow(controller.isProfileLocked) { it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockProfileNull_locksToServerDefault() {
|
||||
runBlocking { controller.lockProfile(null) }
|
||||
|
||||
// Server default is stored as the sentinel (NOT null = unlocked) and the
|
||||
// selection resolves to null (the server-default context).
|
||||
assertEquals(
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
awaitLocked(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY),
|
||||
)
|
||||
assertNull(awaitSelected(null))
|
||||
assertTrue(awaitFlow(controller.isProfileLocked) { it })
|
||||
}
|
||||
|
||||
// --- selectProfile gating while locked ----------------------------------
|
||||
|
||||
@Test
|
||||
fun selectProfile_otherTarget_isNoOpWhileLocked() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// Attempt to switch to a DIFFERENT profile — must be refused.
|
||||
controller.selectProfile(coder)
|
||||
|
||||
// Selection stays on the locked target.
|
||||
assertEquals(mizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectProfile_lockedTarget_isAllowedWhileLocked() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// Re-selecting the locked target is permitted (no-op against state, but
|
||||
// must not be refused outright).
|
||||
controller.selectProfile(mizu)
|
||||
assertEquals(mizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
// --- resolvePendingProfileFrom under a lock -----------------------------
|
||||
|
||||
@Test
|
||||
fun resolvePending_locked_resolvesToLockedTargetWhenPresent() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// The locked profile object refreshes from the advertised list.
|
||||
val refreshedMizu = mizu.copy(model = "model-a-v2")
|
||||
val changed = controller.resolvePendingProfileFrom(listOf(refreshedMizu, coder))
|
||||
|
||||
assertTrue("resolution should report a change (model differs)", changed)
|
||||
assertEquals(refreshedMizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePending_locked_holdsWhenLockedProfileAbsent_thenRecovers() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
// Locked profile NOT in the advertised list → HOLD: selection cleared to
|
||||
// null, return true (changed from the previously-selected mizu).
|
||||
val held = controller.resolvePendingProfileFrom(listOf(coder))
|
||||
assertTrue("HOLD must report a change away from the locked target", held)
|
||||
assertNull("selection must hold on null while the locked profile is gone", controller.selectedProfile.value)
|
||||
|
||||
// A later list arrival that DOES contain the locked profile recovers it —
|
||||
// proves the pending lock name was retained during the HOLD.
|
||||
val recovered = controller.resolvePendingProfileFrom(listOf(mizu, coder))
|
||||
assertTrue("recovery should report a change back to the locked target", recovered)
|
||||
assertEquals(mizu, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePending_lockedToServerDefault_resolvesToNull() {
|
||||
runBlocking { controller.lockProfile(null) }
|
||||
awaitLocked(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
awaitSelected(null)
|
||||
|
||||
// The sentinel resolves to the null (server-default) selection regardless
|
||||
// of the advertised list.
|
||||
val changed = controller.resolvePendingProfileFrom(listOf(mizu, coder))
|
||||
// Selection was already null, so no change is reported; the contract we
|
||||
// care about is that it stays null and never coerces to a list entry.
|
||||
assertFalse("server-default selection was already null — no change", changed)
|
||||
assertNull(controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
// --- unlockProfile re-enables free selection ----------------------------
|
||||
|
||||
@Test
|
||||
fun unlockProfile_clearsLock_andReenablesSelectProfile() {
|
||||
runBlocking { controller.lockProfile(mizu) }
|
||||
awaitLocked("mizu")
|
||||
awaitSelected("mizu")
|
||||
|
||||
runBlocking { controller.unlockProfile() }
|
||||
// Lock cleared (back to unlocked / null) and isProfileLocked flips false.
|
||||
assertNull(awaitLocked(null))
|
||||
assertFalse(awaitFlow(controller.isProfileLocked) { !it })
|
||||
|
||||
// selectProfile to a different target is now honored.
|
||||
controller.selectProfile(coder)
|
||||
assertEquals(coder, controller.selectedProfile.value)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.2.0"
|
||||
appVersionCode = "14"
|
||||
appVersionName = "1.2.1"
|
||||
appVersionCode = "15"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
compose-bom = "2026.05.01"
|
||||
@@ -26,6 +26,7 @@ play-publisher = "4.0.0"
|
||||
media3 = "1.10.1"
|
||||
androidVad = "2.0.10"
|
||||
spatialsdk = "0.12.0"
|
||||
play-app-update = "2.1.0"
|
||||
|
||||
[libraries]
|
||||
# Compose BOM
|
||||
@@ -100,6 +101,15 @@ meta-spatial-sdk-mruk = { group = "com.meta.spatial", name = "meta-spatial-sdk-m
|
||||
# android-vad (Silero) — on-device voice activity detection for barge-in (B2)
|
||||
android-vad-silero = { group = "com.github.gkonovalov.android-vad", name = "silero", version.ref = "androidVad" }
|
||||
|
||||
# Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
|
||||
# Sideload uses the GitHub releases UpdateChecker instead and must NOT pull
|
||||
# this in (it would be dead weight + a Play dependency the sideload track
|
||||
# deliberately avoids). The base `app-update` artifact covers everything the
|
||||
# impl uses (AppUpdateManager, InstallStateUpdatedListener, startUpdateFlow);
|
||||
# `-ktx` is kept as the standard companion for the coroutine/Flow extensions.
|
||||
play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "play-app-update" }
|
||||
play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "play-app-update" }
|
||||
|
||||
# Serialization
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
|
||||
@@ -67,10 +67,14 @@ echo " --------------------------------------------------------"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " 1. Update CHANGELOG.md, RELEASE_NOTES.md, app/src/main/assets/whats_new.txt,"
|
||||
echo " and app/src/googlePlay/play/release-notes/en-US/default.txt"
|
||||
echo " (Play Console 'What's new' — gradle-play-publisher reads this; <=500 chars/lang)"
|
||||
echo " app/src/main/assets/changelog.json, and"
|
||||
echo " app/src/googlePlay/play/release-notes/en-US/default.txt"
|
||||
echo " - whats_new.txt holds ONLY the latest version (auto-dialog fallback +"
|
||||
echo " Play Console 'What's new' source; gradle-play-publisher reads default.txt; <=500 chars/lang)"
|
||||
echo " - changelog.json: PREPEND a new {version,title,date,sections[]} block (newest first)"
|
||||
echo " to versions[]; it backs the in-app What's New dialog + full Settings changelog"
|
||||
echo " 2. Commit on dev:"
|
||||
echo " git add $LIBS CHANGELOG.md RELEASE_NOTES.md app/src/main/assets/whats_new.txt app/src/googlePlay/play/release-notes/en-US/default.txt"
|
||||
echo " git add $LIBS CHANGELOG.md RELEASE_NOTES.md app/src/main/assets/whats_new.txt app/src/main/assets/changelog.json app/src/googlePlay/play/release-notes/en-US/default.txt"
|
||||
echo " git commit -m \"release(android): android-v$NEW_VERSION\""
|
||||
echo " 3. Merge dev -> main, then tag main:"
|
||||
echo " git tag android-v$NEW_VERSION"
|
||||
|
||||
Reference in New Issue
Block a user