* build: compileSdk 36 -> 37 + okhttp 5.4.0 -> 5.5.0 Take okhttp 5.5.0 (folds in dependabot PR #940) which hard-requires compileSdk 37 via AAR metadata. targetSdk stays 36 for now. * build: targetSdk 36 -> 37 (Android 17) + ACCESS_LOCAL_NETWORK gating Opt into Android 17 runtime behavior. The app connects to a gateway (default 127.0.0.1:9119, plus LAN + remote), so targeting 37 forces the new ACCESS_LOCAL_NETWORK permission for LAN hosts. - AndroidManifest: declare ACCESS_LOCAL_NETWORK (harmless below API 37) - LocalNetwork.kt: pure detector + needsLocalNetworkPermission(baseUrl) (loopback + public remote exempt; RFC1918/link-local/CGNAT gated) - ConnectViewModel: requestConnect() gates on the permission; screen launches the prompt; onLanPermissionResult proceeds or shows a clear error - flake.nix: android platform 36 -> 37 - Tests: LocalNetworkTest + ConnectPermissionGateTest (SDK_INT / permission branching mocked, no device needed) - Task 8 finding: assistant services (VoiceInteraction*/Recognition) and the remoteMessaging ChatNotificationService do NOT hit the Android 17 mic/ while-in-use foreground hardening, so no change required. * fix: ktlint + @Composable LocalContext hoist (was blocking CI) - ConnectScreen: hoist LocalContext.current out of onClick lambda (calling a @Composable inside a non-composable lambda broke compileDebugKotlin) - ktlint --format: multiline-expression-wrapping, function-signature, no-empty-first-line-in-class-body, no-unused-imports - LocalNetwork.kt: merge consecutive KDocs (no-consecutive-comments) flake.nix buildToolsVersion/platformToolsVersion intentionally stay 36.0.0 — they are decoupled from compileSdk; only platformVersions needs 37. * fix: suspend mock (coEvery) + flake toolchain pinned to 37.0.0 - ConnectPermissionGateTest: getStatus() is suspend → use coEvery (was breaking compileDebugUnitTestKotlin) - flake.nix: buildToolsVersion/platformToolsVersion/cmdLineToolsVersion pinned to 37.0.0 (verified against pinned nixpkgs 241313f — resolves to android-sdk-*-37.0.0 derivations; platformVersions already 37) * fix(flake): revert cmdLineToolsVersion to 11.0 (not API-aligned) cmdline-tools uses its own versioning (latest = 21.0, never 37.0.0) — nixpkgs rejects 37.0.0 with 'version missing'. build-tools + platform-tools stay 37.0.0 (verified resolve to android-sdk-*-37.0.0). flake now evals clean against pinned nixpkgs 241313f. * flake: track nixos-unstable + pin toolchain to latest (cmdline 22.0, platform-tools 37.0.1, build-tools 37.0.0, platform 37.1) - inputs.nixpkgs -> nixos-unstable, flake.lock bumped to rev ffb3c9b7 (2026-08-19) - cmdLineToolsVersion 11.0 -> 22.0 (own versioning, not API-aligned; v22_0 confirms) - platformToolsVersion 37.0.0 -> 37.0.1 - buildToolsVersion stays 37.0.0 - platformVersions 37 -> 37.1 - verified: nix flake check --no-build -> all checks passed on hyari * fix: unit tests — Uri stub + Build.SDK_INT final-field mock - LocalNetwork.kt: needsLocalNetworkPermission uses java.net.URI instead of android.net.Uri (latter is a non-functional stub in JVM unit tests, its host returns null -> gate always false). Added pure requiresLocalNetworkPermission(sdkInt, baseUrl). - ConnectViewModel: inject sdkVersion (package-private) instead of reading the final static Build.VERSION.SDK_INT directly; gate via requiresLocalNetworkPermission. - ConnectPermissionGateTest: drop mockkStatic(Build.VERSION) (MockKException on final static field, and against repo convention); inject sdkVersion; add pure gate-decision matrix. Fixes 7 red tests. * fix(test): remove illegal ':' from test function names (compile error) * fix(test): order getString mocks so specific resources win over wildcard * fix(flake): include android-37.0 system image so the emulator can boot Google only publishes the android-37.0 system image (no 37.1 image exists yet). With platformVersions=["37.1"] composeAndroidPackages found no matching system image, leaving system-images/ empty and every AVD FATALing with 'Broken AVD system path'. Adding 37.0 pulls the published image so an AVD boots (verified: hermes37 AVD boots Android 17 / API 37 on hyari). compileSdk/targetSdk stay 37. Also bump AGENTS.md SDK note 36 -> 37 (was stale). * fix(flake): platformVersions 37.1 -> 37.0 Drop 37.1; 37.0 provides both the SDK platform for compileSdk 37 and the published android-37.0 system image the emulator needs. * fix(flake): remove stray tool-scratch line leaked into flake.nix (line 116) A terminal tool's /tmp scratch rename line got appended to flake.nix, making it invalid Nix. Removed; flake now parses + 'nix flake check' passes on hyari. * fix(build): pin buildToolsVersion 37.0.0 to match nix SDK AGP defaulted build-tools to 36.0.0, which isn't in the nix-managed SDK (only 37.0.0 is provisioned). Local ./gradlew builds then tried to *install* 36.0.0 into the immutable /nix/store and failed. Pin 37.0.0 so the local nix devShell builds. CI uses its own SDK so unaffected. --------- Co-authored-by: Hy4ri <Hy4ri@users.noreply.github.com>
317 lines
15 KiB
Markdown
317 lines
15 KiB
Markdown
# AGENTS.md
|
|
|
|
Guidance for AI coding agents working on this repository.
|
|
This complements [README.md](README.md) (for humans) with agent-focused context.
|
|
|
|
## Project Overview
|
|
|
|
**Hermes Mobile** is a Jetpack Compose Android app — a mobile control panel for
|
|
[Hermes Agent](https://hermes-agent.nousresearch.com). It talks to the Hermes
|
|
dashboard's REST API and WebSocket TUI Gateway (JSON-RPC 2.0) over a trusted LAN.
|
|
|
|
- **Package:** `com.m57.hermescontrol`
|
|
- **Min SDK 26 / Target SDK 37 / Compile SDK 37**
|
|
- **Kotlin 2.4.10**, KSP 2.3.10 (standalone versioning, NOT `kotlinVersion-kspVersion`)
|
|
- **Jetpack Compose**, Room 2.7.x, Navigation3, OkHttp WebSocket, Retrofit, Kotlinx Serialization
|
|
- **Auth:** `EncryptedSharedPreferences` (AES256-GCM), Bearer token (REST) + `?token=` (WS)
|
|
|
|
## Build & Test Commands
|
|
|
|
### ✅ Local Android SDK — if available
|
|
|
|
If you have a local Android SDK (`ANDROID_HOME` set), these work:
|
|
|
|
```bash
|
|
./gradlew assembleDebug # full APK build
|
|
./gradlew testDebugUnitTest # unit tests (MockK)
|
|
./gradlew ktlintCheck # style check
|
|
```
|
|
|
|
**ktlint standalone** (no SDK needed):
|
|
|
|
```bash
|
|
# Download the matching binary
|
|
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.2.1/ktlint
|
|
chmod +x ktlint
|
|
./ktlint <file> # check one file
|
|
./ktlint --format <file> # auto-fix
|
|
```
|
|
|
|
**No SDK? CI handles everything** — push small and watch the checks below.
|
|
|
|
### CI pipeline (`.github/workflows/android.yml`)
|
|
|
|
| Job | Purpose |
|
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `ktlint` | ktlint 1.2.1 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) |
|
|
| `android-lint` | Android Lint |
|
|
| `unit-tests` | JUnit |
|
|
| `build` | assembleDebug (gated by the 3 above) |
|
|
| `release-compile` | compileReleaseKotlin — catches release-variant compilation issues (e.g. debugImplementation-scoped deps referenced in main source) |
|
|
| `instrumented-tests` | Compose UI tests on Android emulator (API 34 ATD) |
|
|
| `ci-summary` | Aggregator (`if: always()`) — branch protection gates on THIS check |
|
|
|
|
Also: There is a separate workflow `merge-conflict-detector.yml` that auto-labels PRs with `has-conflicts`.
|
|
|
|
Every Gradle job validates `gradle-wrapper.jar` and uses the remote build cache
|
|
(`GRADLE_ENCRYPTED_KEY` secret). `concurrency.cancel-in-progress: true`.
|
|
|
|
### Releasing (`.github/workflows/release.yml`)
|
|
|
|
Triggers on `git push tag v*`. Release APK uses R8 minification + resource shrinking.
|
|
Requires `permissions: contents: write` on the `build-release` job.
|
|
|
|
## Code Style
|
|
|
|
- **ktlint 1.2.1** is enforced in CI. Run `./ktlint --format` before pushing.
|
|
- Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
|
|
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
|
|
- `const val` must use SCREAMING_SNAKE_CASE.
|
|
- Trailing commas required. No trailing whitespace.
|
|
- 120 char max line length.
|
|
|
|
## Architecture Conventions
|
|
|
|
### Navigation (Navigation3 — NOT Navigation Compose)
|
|
|
|
Uses `androidx.navigation3` (`NavKey`, `NavBackStack`, `NavDisplay`, `entry<T>`).
|
|
|
|
**⚠ Always route navigation through `NavigationController.navigateTo()`.** Never call
|
|
`backStack.add(ScreenKey)` directly from UI callbacks — the controller has a
|
|
deduplication guard. Bypassing it stacks duplicate screen entries that compete for
|
|
touch events and become unresponsive.
|
|
|
|
**Drawer gesture state is screen-owned (issue #619).** Each screen declares whether
|
|
the modal drawer's swipe gestures should be active while it is visible, via a single
|
|
source of truth — no global gesture set, no defensive `LaunchedEffect(snapTo(Closed))`,
|
|
no `closeDrawer` callback on `NavigationController`.
|
|
|
|
- `HermesScaffold(drawerGesturesEnabled = true)` — default; primary screens and
|
|
most list screens. The scaffold reconciles this preference into the
|
|
`DrawerGestureController` via a `SideEffect`.
|
|
- `HermesScaffold(drawerGesturesEnabled = false)` — drill-down sub-pages (e.g.
|
|
`SettingsConnectionPage`, `SettingsAppearancePage`, …). The controller closes
|
|
the drawer itself if it was open when the screen composed, so the scrim can't
|
|
stick around and intercept the next tap.
|
|
- `DisableDrawerGestures()` — for entry screens that don't use `HermesScaffold`
|
|
(Landing, AuthLogin, PairingCodeEntry).
|
|
|
|
`ModalNavigationDrawer` in `Navigation.kt` reads `gestureController.enabled`
|
|
(provided via `LocalDrawerGestureController`) and passes it to Material's
|
|
`gesturesEnabled` parameter. To change a screen's gesture behavior, edit the
|
|
screen — not `Navigation.kt`.
|
|
|
|
### Activity-Scoped ViewModels
|
|
|
|
Some ViewModels (e.g. `AuthLoginViewModel`) are created at the Activity scope via
|
|
`viewModel()` at the navigation entry level. This means they **survive navigation**
|
|
and cached state like `connectionSuccess` persists across screen entries.
|
|
|
|
Always pair transient state flags with a `DisposableEffect` cleanup:
|
|
|
|
```kotlin
|
|
DisposableEffect(Unit) {
|
|
onDispose { viewModel.clearTransientState() }
|
|
}
|
|
```
|
|
|
|
The cleanup function should reset self-transitioning flags (`connectionSuccess`,
|
|
`isLoading`, `errorMessage`) so the screen can re-enter cleanly.
|
|
|
|
### WebSocket Lifecycle
|
|
|
|
`HermesWsClient` is a singleton that outlives individual screens. It must be
|
|
explicitly disconnected on logout and reconnected after login:
|
|
|
|
| Event | Action |
|
|
| ------------- | ------------------------------------------------------ |
|
|
| Logout | `HermesWsClient.disconnect()` before clearing tokens |
|
|
| Login success | `HermesWsClient.connect()` after `ApiClient.rebuild()` |
|
|
|
|
The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to
|
|
call unconditionally.
|
|
|
|
### Shared Components
|
|
|
|
- **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot,
|
|
pull-to-refresh, optional snackbar host. New screens MUST use this instead of raw
|
|
`Scaffold`. API: `title: @Composable () -> Unit` (not `String` — wrap in
|
|
`{ Text("...") }`).
|
|
- **`StateViews`** — `LoadingState`, `ErrorState`, `EmptyState`. Every data screen
|
|
must implement all three branches in its `when { }` block.
|
|
|
|
### ⚠ HermesScaffold Padding Foot-Gun
|
|
|
|
**This is the #1 recurring bug in this codebase.** It has been re-introduced on 4+ screens
|
|
across 3+ PRs (Settings, Achievements, Webhooks, Config — PRs #445, #454, #455).
|
|
|
|
**Root cause:** `HermesScaffold` wraps content in an internal `Box(Modifier.padding(paddingValues))`
|
|
that already offsets for the top bar. But it also passes `paddingValues` into the content lambda,
|
|
which looks like it should be applied — and every new screen does exactly that:
|
|
|
|
```kotlin
|
|
HermesScaffold(...) { paddingValues -> // ← scaffold already handles top bar offset via Box
|
|
LazyColumn(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(paddingValues), // ← ❌ double-stacked top padding
|
|
contentPadding = listContentPadding, // ← extra 8dp, now triple-stacked
|
|
) { ... }
|
|
}
|
|
```
|
|
|
|
**The deeper issue:** Passing `paddingValues` into the lambda implies "you need to use this,"
|
|
when the scaffold has ALREADY pre-applied it in its own outer `Box`. This API design creates
|
|
a natural foot-gun: every developer instinctively adds `.padding(paddingValues)` on inner
|
|
content because it seems correct.
|
|
|
|
**Correct pattern — do NOT apply `paddingValues` on inner content:**
|
|
|
|
```kotlin
|
|
// ✅ List screens:
|
|
LazyColumn(
|
|
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
|
|
contentPadding = listContentPadding, // this is the ONLY padding needed
|
|
verticalArrangement = listItemSpacing,
|
|
)
|
|
|
|
// ✅ Non-list screens (Column/Box wrapping):
|
|
Column(
|
|
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
|
|
) { ... }
|
|
|
|
// ✅ Loading/Error/Empty states — these DO need it:
|
|
LoadingState(modifier = Modifier.padding(paddingValues))
|
|
```
|
|
|
|
**Quick test:** If your screen's top gap is wider than CronJobsScreen's, you've double-stacked.
|
|
|
|
**See also:** `references/hermes-scaffold-padding.md` in the skill doc for the full
|
|
breakdown, edge cases, and timeline of previous occurrences.
|
|
|
|
### Room Persistence
|
|
|
|
- `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive
|
|
app kills. `getMessagesForSession()` returns `suspend fun ...: List<ChatMessageEntity>`
|
|
(not `Flow` — the caller controls the coroutine scope).
|
|
- Room 2.7.x requires `room { schemaDirectory("$projectDir/schemas") }` DSL.
|
|
- `ChatViewModel` extends `AndroidViewModel` (needs Application for DB access).
|
|
|
|
### Theme
|
|
|
|
`Theme.kt` uses a preset-based theme system with 6 built-in presets: Default,
|
|
Monochrome, Gruvbox, Catppuccin, AMOLED, and Nord. Each preset provides light
|
|
and dark color schemes plus matching semantic status colors (AMOLED is
|
|
dark-only — light mode falls back to the default theme).
|
|
Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw
|
|
`PaletteColors` + `HermesStatusColors` mapped into the Material 3
|
|
`ColorScheme` slots, with the error slots derived from the status set.
|
|
|
|
Dynamic color (Material You on API 31+) is controlled by a `useDynamicColors`
|
|
parameter on `HermesControlTheme`, not a compile-time constant. When dynamic
|
|
colors are active they override the preset's `ColorScheme`, but semantic status
|
|
colors (success/warning/error/info) are ALWAYS resolved from the active preset
|
|
via `LocalHermesStatusColors` — never from `MaterialTheme.colorScheme`.
|
|
|
|
Access status colors via `LocalHermesStatusColors.current.success`, not
|
|
`MaterialTheme.colorScheme`.
|
|
|
|
## Git Workflow
|
|
|
|
### PR-Always (ENFORCED)
|
|
|
|
**Every change goes through a PR. Never push directly to `main`.**
|
|
|
|
```bash
|
|
git checkout main && git pull origin main
|
|
git checkout -b fix/issue-N-description # or feat/...
|
|
# make changes, run ./ktlint --format
|
|
git commit -m "fix(#N): description"
|
|
git push -u origin HEAD
|
|
gh pr create --title "fix(#N): description" --body "Closes #N"
|
|
```
|
|
|
|
### Commit Conventions — STRICT
|
|
|
|
**⚠ Agent-authored commits:** Do NOT override git authorship (`--author`) with the agent's identity. Use the default git config. No `Co-Authored-By` or `Generated with` trailers.
|
|
|
|
- **Short commits:** subject line + max 2 lines of body. Split into atomic commits rather than writing long bodies.
|
|
- **Conventional Commits** types: `feat`, `fix`, `refactor`, `docs`, `test`, `ci`, `chore`.
|
|
- Bug fixes annotated inline with the tracking issue/regression ID.
|
|
|
|
## Security Considerations
|
|
|
|
- **Cleartext HTTP/WS is intentional** — trusted LAN only. `usesCleartextTraffic="true"`
|
|
and `http://`/`ws://` URLs are by design. Don't "fix" this unless adding HTTPS support.
|
|
- **`HttpLoggingInterceptor.Level.BODY`** must stay gated on `BuildConfig.DEBUG` — it
|
|
prints the Authorization header. (B1)
|
|
- **AuthManager token** is ephemeral — it becomes invalid when the Hermes dashboard
|
|
restarts. Symptom: 401 on every call. Fix: re-extract the token from the running
|
|
gateway, don't assume the stored one is valid.
|
|
- **Release signing** uses a keystore with env-var credentials (`KEYSTORE_PATH`,
|
|
`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`). Don't commit keystore files or
|
|
hardcode passwords.
|
|
|
|
## Things to Avoid
|
|
|
|
- Don't run `./gradlew` tasks without an Android SDK — CI handles compilation, lint, and tests.
|
|
- Don't remove `@OptIn(ExperimentalMaterial3Api::class)` from screens that use
|
|
`SegmentedButton`, `PullToRefreshBox`, or other experimental M3 APIs.
|
|
- Don't access `MaterialTheme.colorScheme.*` inside non-`@Composable` lambdas
|
|
(`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local
|
|
`val` at the composable scope first.
|
|
- Don't add new screens without checking if an existing one already covers the
|
|
functionality (28+ screens exist). Extend rather than duplicate.
|
|
- **⚠ Never scope a dependency to `debugImplementation` if its import is used in
|
|
`main/` source code.** The CI `release-compile` job catches this, but save the
|
|
cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's
|
|
imported in `ApiClient.kt` (main source) so it must be `implementation`, not
|
|
`debugImplementation`. If you want it debug-only, wrap the usage in
|
|
`if (BuildConfig.DEBUG)` or extract it behind an interface.
|
|
|
|
### Hardcoded Color Guard (issue #622)
|
|
|
|
A custom Gradle task `checkColorLiterals` fails the build if `Color(0x...)` hex
|
|
literals or named `Color.White`/`Color.Black`/etc. appear outside the allowed
|
|
paths (`theme/`, `*Preview.kt`, `PairingScreen.kt`, `AuthLoginScreen.kt`).
|
|
|
|
Use `MaterialTheme.colorScheme.<token>`, `LocalHermesStatusColors.current.<semantic>`,
|
|
or `Color.Transparent` / `Color.Unspecified` instead. The guard runs in CI as part
|
|
of the `ktlint` job.
|
|
|
|
## Project Layout
|
|
|
|
```
|
|
com.m57.hermescontrol/
|
|
├── data/
|
|
│ ├── config/ ServerStore, ConnectionProfile, migrations
|
|
│ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore
|
|
│ ├── model/ 40+ data classes for API responses + requests
|
|
│ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management
|
|
│ ├── session/ ActiveSessionHolder
|
|
│ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository
|
|
├── notification/ ChatNotificationService, NotificationReplyReceiver
|
|
├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors
|
|
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
|
|
├── ui/
|
|
│ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows
|
|
│ └── 28 feature packages (achievements, analytics, authlogin, billing, channels,
|
|
│ chat, config, connect, cron, gateway, kanban, keys, landing, logs, mcp,
|
|
│ model, pairing, plugins, process, profiles, providers, sessions, settings,
|
|
│ skills, system, toolsets, webhooks)
|
|
├── util/ CronExpressionFormatter, LocaleContextWrapper
|
|
├── HermesControlApp.kt Application class
|
|
├── Navigation.kt Drawer + NavDisplay + entry wiring
|
|
├── NavigationController.kt Central navigation guard (dedup)
|
|
├── NavigationKeys.kt @Serializable NavKey data objects (28 screens + 6 settings sub-pages)
|
|
├── ScreenRegistry.kt entry<T> registrations for all NavKeys
|
|
└── MainActivity.kt
|
|
```
|
|
|
|
## Further Reading
|
|
|
|
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
|
|
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
|
|
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
|