Adds the phone side of the pairing contract v1: payload parsing, gateway resolve, parallel hello and a single claim, per host:port certificate pinning hooked into OkHttpProvider.base, and no dashboard token refresh for a paired IDE. Adds zxing-android-embedded and okhttp-tls (tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
* 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>
* feat(chat): render LaTeX formulas
Support native inline and display math with accessible Compose rendering.
* perf(chat): reuse LaTeX parsing state
Share one measurer per message and skip redundant math splitting.
* feat(#471): implement DataStore-based server configuration and self-healing
* refactor(#471): address Sourcery feedback on ANR risk and reset method splitting
* refactor(#471): implement synchronous ServerStore initialization to fully remove ANR risk
* feat(#471): show profile name, host and port on add profile, redirect to login on save, and support shortcut login buttons for existing profiles
* refactor: migrate Gson serialization to kotlinx.serialization
* fix(dto): resolve serializing Any? and Map<String, Any?> with JsonElement
* test(refactor): fix unit test issues under kotlinx.serialization and update nullable DTO defaults
* fix(chat): support flexible numeric and string decoding for message timestamps
* fix(chat): sequentialize cache loading to avoid overwriting network state
* fix(#472): refactor ChatViewModelTest to use Fake* + Turbine
Replace Mockk-heavy test setup with FakeChatPersistenceRepository (+
in-memory FakeChatMessageDao) and add Turbine for Flow assertions.
Changes:
- Add Turbine v1.2.0 dependency (libs.versions.toml + build.gradle.kts)
- Create FakeChatMessageDao – in-memory DAO with MutableStateFlow
- Create FakeChatPersistenceRepository – delegates to FakeDao
- ChatViewModel: make repo injectable (@VisibleForTesting lateinit var)
- Rewrite ChatViewModelTest (751 lines vs 1,212): clearer helpers,
Turbine test for streaming Flow sequence, same 29 tests with
identical assertions, ~38% line reduction
Test: ktlint --format passes clean
* fix(#472): address review — constructor injection, ConcurrentHashMap, MockWebServer tests
Addresses all feedback from Sourcery review on PR #474:
Constructor injection (was lateinit var):
- Move repo to ChatViewModel primary constructor with default
- Remove @VisibleForTesting lateinit var + init block assignment
- Tests pass repo directly via constructor
Simplify FakeChatMessageDao:
- Replace manual synchronized(lock) with ConcurrentHashMap
- Same thread safety, 48% less code, no boilerplate lock object
New MockWebServer tests:
- HermesApiServiceMockWebServerTest: 6 tests covering getStatus,
getSessions, getSessionMessages + edge cases (null fields,
empty lists, slash-encoded session IDs)
* fix: CI compilation errors — open class, TestScope receiver, runBlocking
Three compilation errors blocking CI:
- ChatPersistenceRepository was final, but FakeChatPersistenceRepository
extends it → make it open class
- createViewModelWithSession() called advanceUntilIdle (TestScope ext)
and emit() (suspend) without a TestScope receiver or coroutine ctx →
make it fun TestScope.xxx() and use tryEmit() for non-suspend emission
- HermesApiServiceMockWebServerTest called suspend API methods from
regular @Test methods → wrap each test in runBlocking
* fix: test runtime failures — suspend helper + fix JSON escaping
- Use so emit()
(suspend) is valid and events are delivered synchronously instead of
buffered via tryEmit() which was causing 17 test failures
- Fix MalformedJsonException in MockWebServer test by using plain string
content instead of unescaped JSON-within-JSON
* fix: correct req-id values in ChatViewModelTest — SESSION_CREATE is req-id-3
* fix(test): correct ChatViewModelTest assertions for Fakes+Turbine refactor
- testMessageStreamingFlow: collect streamingState via Turbine
(ThinkingDelta/MessageToken mutate streamingState, not uiState)
- testToolExecution_finalizesPreviousStreamingMessage:
account for messages[0] = "Session created" system message
- testMessageStart_finalizesPreviousStreamingMessage:
same index offset (messages[1] is the finalized stream)
- testClarifyRequestCustomResponse: options is emptyList(), not null;
message count is 3 (session-create + user clarify reply)
Fixes CI failures introduced in PR #474.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#350): pairing login flow with QR scan + manual entry
Adds PairingCodeEntryScreen with CameraX + ML Kit QR scanning
and manual code entry. The screen parses hermes://connect URIs,
Base64-encoded JSON, and raw token formats, validates via
/api/status, and saves credentials on success.
- New PairingCodeEntryScreen + PairingCodeEntryViewModel
- CameraX 1.4.1 + ML Kit barcode-scanning 17.3.0
- CAMERA permission + proguard rules
- Landing screen Pairing Login button wired correctly
* fix(#350): resolve smart-cast on delegated property for errorMessage
Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>
* refactor(#421): remove QR scanner from pairing login
- Remove CameraX + ML Kit barcode dependencies
- Drop CAMERA permission and camera feature from manifest
- Strip QrScannerTab, QrCodeScannerView, processFrame composables
- Remove camera permission state/methods from ViewModel
- Purge all QR/camera strings from strings.xml
- Delete ML Kit proguard rules
The pairing login now shows only the manual code entry UI.
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
* feat(#195): add chat attachment support for files and images
- Add Attachment data model with computed properties (isImage, formattedSize, fileExtension)
- Extend ChatMessage with optional attachments list
- Add Coil 2.7.0 for async image loading in Compose
- Extend ChatViewModel with pending attachment state, addAttachment/removeAttachment/clearAttachments
- Build attachment-prefixed message text (data URIs for images, file refs for others)
- Add paperclip button and file picker launcher (ActivityResultContracts.GetContent) to ChatScreen
- Add animated attachment preview chips with thumbnails before sending
- Render attachments inline in UserBubble (AsyncImage for images, file cards for others)
- Add string resources for attachment UI features
* fix(#195): rewrite attachment upload protocol to match Hermes backend RPC methods
BREAKS: removes old buildAttachmentText() that embedded base64 data URIs in prompt text.
Changes:
- Replace inline data-URI encoding with proper backend RPC flow:
- image.attach_bytes for images (fire-and-forget, auto-queues to session)
- file.attach for non-image files (awaited, returns @file: ref text)
- Add CompletableDeferred to PendingRequest so sendMessage can await RPC responses
- Add readContentUriBytes() to read file content from content:// URIs
- Add sendRpcAndAwait() suspend function for sequential RPC calls
- Add RpcCallException for awaited RPC error handling
- Suppress error UI for awaited RPCs (caller handles them directly)
- Keep ChatScreen/ChatBubble UI layer unchanged (file picker, previews, inline images)
* feat(#195): add camera capture support via FileProvider + TakePicture contract
- Creates temporary photo files in cacheDir via FileProvider
- Launches system camera with ActivityResultContracts.TakePicture
- Adds camera icon button (📷) next to the paperclip in ChatInputBar
- Captured photos added as image/jpeg attachments via same addAttachment flow
- FileProvider configured with cache-root path in res/xml/file_paths.xml
- Error handling for launch failures and capture failures with snackbar
* refactor(#195): group camera/image/file into single attach dropdown
- Replaces separate 📷 and 📎 buttons with a single 📎 button
- DropdownMenu opens with three options:
- 📷 Camera → TakePicture via FileProvider temp URI
- 🖼️ Image → GetContent("image/*")
- 📄 File → GetContent("*/*")
- Removes onAttachClick, adds onImageTap/onFileTap callbacks
- Input bar stays clean with one attachment affordance
* fix(#195): add missing imports for CI compilation
ChatScreen.kt:
- android.util.Log
- androidx.compose.animation.core.* (infiniteRepeatable, tween, LinearEasing, RepeatMode, rememberInfiniteTransition)
- androidx.compose.foundation.layout.widthIn
- androidx.compose.material.icons.filled.AttachFile
- androidx.compose.material.icons.filled.InsertDriveFile
- androidx.compose.ui.layout.ContentScale
- coil.compose.AsyncImage
ChatBubble.kt:
- androidx.compose.material.icons.filled.InsertDriveFile
* fix(#195): resolve android-lint LocalContextGetResourceValueCall error
Replace context.getString(R.string.chat_camera_error) with a
composable-level cameraErrorMsg captured via stringResource()
to avoid querying resources via LocalContext.current in a callback.
* fix(#195): migrate InsertDriveFile to AutoMirrored, fix chip double-remove
- Replace deprecated Icons.Filled.InsertDriveFile with
Icons.AutoMirrored.Filled.InsertDriveFile in ChatBubble.kt
and ChatScreen.kt
- Remove onClick from AttachmentChip Surface so only the
dedicated close button triggers removal, not accidental taps
on the chip body
---------
Co-authored-by: Hermes Agent <hermes@agent.local>