Imported from matsuyoshi30/Quire (
AGENTS.md). Install upstream withnpx skills add matsuyoshi30/Quire. Copyright stays with the author.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
Quire is a personal event journal for Android + Wear OS. Multiple OS sources (Health Connect sleep, geofences, Calendar Provider, watch markers) are normalized into one canonical Event, persisted locally, then delivered to a Discord webhook. The phone is the source of truth; Discord is a delivery target, not storage.
See ARCHITECTURE.md for the full system design and per-source pipeline flows, and USAGE.md for setup, permissions, and feature-by-feature behavior. Read ARCHITECTURE.md before adding or changing a source pipeline.
Build & test
Requires JDK 17. Gradle wrapper is 9.4.0.
./gradlew test # all unit tests across modules
./gradlew :core:test # one module
./gradlew :core:test --tests "*EventSyncServiceTest" # one test class
./gradlew :app:assembleDebug :wear:assembleDebug # build both apps
./gradlew :app:installDebug # install phone app to connected device
:app, :storage, and :wear run unit tests on Robolectric (isIncludeAndroidResources = true); no emulator is needed for ./gradlew test. For installing the wear app to the correct device when multiple are attached, see the adb -s <watch-serial> note in USAGE.md.
Wear device debugging
When local :wear:testDebugUnitTest and :wear:lintDebug are green but the physical watch is still wrong, prefer device-first validation. In this repo, the recurring failures were packaged-manifest mismatches, stale watch installs, and tile renderer/runtime contract bugs that local tests did not expose.
- Classify the symptom first: app missing from launcher, tile missing from the picker, stale or missing tile preview, live tile blank, tile click no-op, or battery drain. They map to different layers, so starting from "tile is broken" is usually too vague to debug efficiently.
- Validate the packaged artifact before blaming the device. Use the final APK under
wear/build/outputs/apk/debug/, not a guessedintermediates/...apk, and inspect the merged manifest when launcher/tile registration looks suspicious.
./gradlew :wear:assembleDebug :wear:processDebugMainManifest
ls -lt wear/build/outputs/apk/debug/
- Target the watch explicitly and keep log windows scoped to the current install. Old watch crashes are easy to misread as current behavior.
adb devices -l
adb -s <watch-serial> install -r -t wear/build/outputs/apk/debug/wear-debug.apk
adb -s <watch-serial> logcat -c
adb -s <watch-serial> logcat | rg 'MarkerTileService|MarkerDispatch|AndroidRuntime|ProtoLayout|Tile'
- If the tile is missing or a tile click does nothing, inspect packaged manifest state and on-device package-manager state. A tile-launched activity must be
exported="true"because the renderer starts it from outside the app process.
adb -s <watch-serial> shell dumpsys package com.matsuyoshi30.quire
- Repo-specific regressions worth checking early:
- Missing
<uses-library android:name="com.google.android.wearable" android:required="true" />hid the app from the watch launcher. PendingIntenttile click wiring crashed only on-device withClickable.Builder.setOnClick(PendingIntent) needs to be called with constructor that accepts ProtoLayoutScope.- A tile can disappear from the picker even when source and merged manifest still look correct; stale watch APKs and tile-picker cache are common causes, so reinstall and recreate the tile before assuming a code regression.
- Missing
- For watch battery investigations, do not infer the root cause from
batterystatsalone. First add short temporary logs aroundWearMarkerSettings.refreshFromPhoneIfStale,MarkerDispatch.send, and the phone-side message receiver so you can count actual refresh/send frequency per user action.
Module boundaries (enforced by design)
:core— pure Kotlin JVM module (kotlin("jvm")). CanonicalEvent, all formatters, aggregation (DailyDigest,SleepSummary),EventStoreinterface,EventSyncService, and theDiscordWebhookClient. Keep this Android-free: no Android imports, no providers, no permissions, no WorkManager. This is where new domain shapes and formatters go first.:storage— Android library. Room implementation ofEventStore(RoomEventStore,EventDao,EventEntity). Persistence details stay here; the rest of the system depends only on the:coreEventStoreinterface.:app— the phone app. All Android lifecycle, OS callbacks/providers, permissions, WorkManager, and the Compose settings UI live here.:wear— watch app. A lightweight input device only: it sends a marker key over the Wear Data Layer and never persists or posts to Discord.
The store-before-send invariant
EventSyncService is the single delivery path and the core reliability decision:
saveAndSend(event)inserts into the store first, then attempts the webhook.delivered_atis written only after a successful webhook response.retryUndelivered()(driven byRetrySyncWorker, every 15 min) replays rows wheredelivered_atis null.
Any new feature MUST route through EventSyncService. Code that posts to Discord directly or skips the store works against the architecture. Idempotency comes from stable Event.id values plus Room PRIMARY KEY + ON CONFLICT IGNORE — choose an id scheme that matches the desired dedup window (see "Why stable IDs matter" in ARCHITECTURE.md).
Adding a new signal source
- Model the domain shape in
:core. - Add a
:coreformatter that turns it into anEvent. - Capture the raw OS/provider input in
:app(or:wearfor watch input). - Hand off to a WorkManager worker if delivery may outlive the originating callback.
- Reuse
EventSyncServicefor delivery.
Background work cadence and the worker list (MorningSleepWorker 07:00, NightlyDigestWorker 21:30, CalendarScanWorker/RetrySyncWorker every 15 min) are scheduled from WorkerScheduler, bootstrapped by MainActivity on first launch.
Configuration & privacy boundaries
- Secrets and personal coordinates come from
local.properties(discord.webhook.url,maps.api.key,geofence.places.json,watch.markers.json) and are surfaced viaBuildConfiginapp/build.gradle.kts. Editinglocal.propertiesrequires a rebuild to take effect. Markers/places are seeded fromBuildConfigdefaults but then editable in-app at runtime. - Geofence coordinates never enter the
Eventmodel (only semantic place names do). Calendar titles are masked by default. Discord receives formatted text, not raw payloads. Preserve these boundaries when extending.
Conventions
- New behavior should be covered by a unit test in the owning module; the pure-
:corepieces (formatters, aggregation, sync, id schemes) are the easiest and most valuable to test directly. Robolectric covers worker/receiver logic in:app/:wear/:storage. - Prefer adding passive background behavior over expanding the phone UI — the UI is intentionally a minimal bootstrap/debug surface.