Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 55 additions & 40 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,61 @@

# AGENTS.md

**Precedence:** The **closest AGENTS.md** to changed files wins. Root holds global defaults only.
**Precedence:** The **closest `AGENTS.md`** to the files you're changing wins. Root holds global defaults only.

## Project Overview
## Overview

KidSync -- privacy-first co-parenting coordination app with E2E encryption.
Local-first, append-only OpLog. Server is a dumb encrypted relay (cannot decrypt user data).

| Component | Stack | Entry Point |
|-----------|-------|-------------|
| Server | Kotlin 2.1.0, Ktor 3.0.3, Exposed ORM, SQLite WAL, JDK 21 | `server/.../Application.kt` |
| Android | Kotlin, Jetpack Compose, Room + SQLCipher, BouncyCastle, Hilt | `android/.../ui/MainActivity.kt` |
| Specs | Markdown + YAML + JSON test vectors | `docs/`, `tests/conformance/` |
## Commands

## Global Rules
| Task | Command | ~Time |
|------|---------|-------|
| Server tests | `docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle test --no-daemon` | ~90s |
| Conformance | `python3 tests/conformance/verify_conformance.py` | ~2s |
| Server build | `docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle buildFatJar --no-daemon` | ~60s |
| Docker image | `docker build -t kidsync-server server/` | ~120s |
| Android tests | `cd android && ./gradlew test` | ~120s |
| Android build | `cd android && ./gradlew assembleDebug` | ~90s |

- Conventional Commits: `type(scope): subject`
- All data models use `kotlinx.serialization`
- Hash chain: `SHA256(hexDecode(prevHash) + base64Decode(encryptedPayload))`
- AAD format: `familyId|deviceId|deviceSequence|keyEpoch`
- No Java locally -- server tests run via Docker
## File Map

```
server/ # Kotlin/Ktor relay server (routes, services, DB, plugins)
android/ # Jetpack Compose Android app (crypto, data, domain, UI)
docs/ # Protocol specs (wire-format, sync-protocol, encryption-spec), OpenAPI, reviews
tests/ # Conformance test vectors (JSON + Python verifier)
```

## Golden Samples

| For | Reference | Key patterns |
|-----|-----------|--------------|
| Route handler | `server/.../routes/AuthRoutes.kt` | Service delegation, ApiException, rate limiter |
| DB transaction | `server/.../db/DatabaseFactory.kt` | Explicit DB ref, `throw ApiException` for rollback |
| Use case | `android/.../usecase/sync/SyncOpsUseCase.kt` | Repository injection, suspend, error handling |
| ViewModel | `android/.../viewmodel/AuthViewModel.kt` | `@HiltViewModel`, StateFlow, viewModelScope |
| Composable | `android/.../screens/auth/LoginScreen.kt` | Material 3, collectAsStateWithLifecycle |

## Heuristics

| When | Do |
|------|-----|
| Adding server endpoint | Follow Route -> Service -> dbQuery pattern |
| Touching crypto code | Ask first -- protocol spec changes need approval |
| Adding dependency | Ask first -- we minimize deps |
| Unsure about pattern | Check Golden Samples above |
| Exposed `deleteWhere` with `<`, `<=` | Explicit import: `import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq` |

## Boundaries

### Always Do
- Run server tests before committing: `docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle test --no-daemon`
- Run server tests before committing (see Commands above)
- Use `throw ApiException(...)` inside `dbQuery {}` blocks (NOT `return Result.failure`)
- Pass explicit database reference in Exposed transactions
- Use conventional commit format: `type(scope): subject`
- Show test output as evidence before claiming work is complete

### Ask First
- Changing protocol specs, database schema, encryption/key management code, or dependencies
Expand All @@ -41,36 +69,23 @@ Local-first, append-only OpLog. Server is a dumb encrypted relay (cannot decrypt
- Use `return@dbQuery Result.failure()` inside transactions (breaks rollback)
- Leak exception details to clients

## Security
## Terminology

- E2E encrypted: X25519 key agreement + AES-256-GCM
- Auth: Ed25519 challenge-response. Sessions: opaque tokens (1h TTL)
- CORS restricted via `KIDSYNC_CORS_ORIGINS` env var
- Rate limiting per endpoint. `FLAG_SECURE` on sensitive screens.

## Testing (456 server tests, 881+ Android tests)

```bash
docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle test --no-daemon
python3 tests/conformance/verify_conformance.py # Conformance vectors
```

## Documentation

| Document | Path |
|----------|------|
| Specs | `docs/protocol/wire-format.md`, `sync-protocol.md`, `encryption-spec.md` |
| OpenAPI | `docs/api/openapi.yaml` |
| Reviews | `docs/reviews/phase1-review.md`, `phase4-6-review.md`, `phase7-server-review.md` |
| Ops | `docs/disaster-recovery.md`, `docker-compose.yml`, `.env.example` |
| Term | Means |
|------|-------|
| OpLog | Append-only operation log (encrypted ops synced between devices) |
| Bucket | A family's data container (replaces "family" in sync context) |
| DEK | Data Encryption Key -- AES-256-GCM key, wrapped per device |
| AAD | Additional Authenticated Data: `familyId\|deviceId\|deviceSequence\|keyEpoch` |
| Hash chain | `SHA256(hexDecode(prevHash) + base64Decode(encryptedPayload))` |

## Index of Scoped AGENTS.md
## Index of scoped AGENTS.md

| Path | Scope |
|------|-------|
| `server/AGENTS.md` | Ktor sync server (routes, services, DB, plugins) |
| `android/AGENTS.md` | Android app (crypto, data, domain, UI) |
| [server/AGENTS.md](./server/AGENTS.md) | Ktor sync server -- routes, services, DB, plugins, rate limiting |
| [android/AGENTS.md](./android/AGENTS.md) | Android app -- crypto, data layer, domain logic, Compose UI |

## When Instructions Conflict
## When instructions conflict

Nearest AGENTS.md wins. User prompts override files. Protocol specs override implementation code.
Nearest `AGENTS.md` wins. User prompts override files. Protocol specs override implementation code.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ docker run --rm -v "$(pwd)/server:/app" -w /app gradle:8.12-jdk21 gradle buildFa

## Testing Requirements

- Server: All 456 tests must pass (`gradle test`)
- Server: All 464 tests must pass (`gradle test`)
- Android: Unit tests must pass
- New features should include appropriate test coverage

Expand Down
148 changes: 111 additions & 37 deletions android/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
<!-- FOR AI AGENTS - Scoped to android/ -->
<!-- Last updated: 2026-02-25 -->
<!-- Managed by agent: keep sections and order; edit content, not structure -->
<!-- Last updated: 2026-02-25 | Last verified: 2026-02-25 -->

# Android AGENTS.md

## Overview

Jetpack Compose Android app. Local-first with E2E encrypted sync to Ktor server.
Clean architecture (UI -> Domain -> Data -> Crypto), Hilt DI, Room + SQLCipher, WorkManager sync.

## Stack
**Stack:** Kotlin, Jetpack Compose, Room + SQLCipher, BouncyCastle + JCA, Hilt, WorkManager, Retrofit, min SDK 26, target SDK 35

Kotlin, Jetpack Compose, Room + SQLCipher, BouncyCastle + JCA (crypto), Hilt (DI), WorkManager, Retrofit, min SDK 26, target SDK 35
## Setup

## Package Map
- Android Studio with Kotlin plugin
- JDK 17 for compilation
- No server dependency for unit tests (mocked)

Release signing reads from env vars or `local.properties`:
- `KIDSYNC_KEYSTORE_PATH` / `keystore.path`
- `KIDSYNC_KEYSTORE_PASSWORD` / `keystore.password`
- `KIDSYNC_KEY_ALIAS` / `key.alias`
- `KIDSYNC_KEY_PASSWORD` / `key.password`

Only activates when all 4 values present.

## Commands

| Task | Command | ~Time |
|------|---------|-------|
| Test (all) | `./gradlew test` | ~120s |
| Build debug | `./gradlew assembleDebug` | ~90s |
| Build release | `./gradlew assembleRelease` | ~90s |

## File Map

```
app/src/main/java/com/kidsync/app/
KidSyncApplication.kt # @HiltAndroidApp
crypto/
CryptoManager.kt # Interface + buildPayloadAad() helper
TinkCryptoManager.kt # AES-256-GCM encrypt/decrypt, X25519 (uses BouncyCastle + JCA)
TinkCryptoManager.kt # AES-256-GCM encrypt/decrypt, X25519 (BouncyCastle + JCA)
data/
local/ # Room DB, DAOs, entities, converters
remote/api/ # ApiService (Retrofit), DTOs
Expand All @@ -36,56 +60,106 @@ app/src/main/java/com/kidsync/app/
navigation/ # NavGraph.kt, Routes.kt (sealed class)
screens/ # auth/, calendar/, dashboard/, expense/, family/, settings/
theme/ # Color, Type, Theme (Material 3 + dynamic colors)
viewmodel/ # AuthViewModel, CalendarViewModel, ExpenseViewModel, FamilyViewModel, SettingsViewModel
viewmodel/ # AuthVM, CalendarVM, ExpenseVM, FamilyVM, SettingsVM
```

## Commands
## Testing (881+ tests)

| Command | Purpose |
|---------|---------|
| Open `android/` in Android Studio | IDE setup |
| `./gradlew test` | Run unit tests |
| `./gradlew assembleRelease` | Build release APK (requires signing config) |
| `./gradlew assembleDebug` | Build debug APK |
All tests are unit tests using JUnit, Mockito/MockK, and Kotlin coroutines test.

## Architecture Layers
Test pattern: mock repositories/DAOs, inject into use case or ViewModel, assert StateFlow emissions.

```
UI (Compose screens + ViewModels)
↓ StateFlow + collectAsStateWithLifecycle
Domain (use cases, models, repository interfaces)
↓ suspend functions
Data (Room DAOs, Retrofit API, repository impls, SyncWorker)
Crypto (BouncyCastle + JCA: AES-256-GCM, X25519, HKDF, BIP39)
```
## Code Style

All ViewModels: `@HiltViewModel`, `viewModelScope`, `MutableStateFlow`/`StateFlow`
- `@HiltViewModel` + `viewModelScope` + `MutableStateFlow`/`StateFlow` for all ViewModels
- `collectAsStateWithLifecycle` in Composables (never `collectAsState`)
- Clean architecture: UI -> Domain -> Data (repositories bridge the layers)
- `SharedPreferences.commit()` for security-critical state, `.apply()` only for non-critical
- Sealed class `Routes` with `data object` entries for navigation
- `popUpTo` with `inclusive = true` at auth flow transitions

## Critical Patterns
## Security

**AAD construction**: `CryptoManager.buildPayloadAad(familyId, deviceId, deviceSequence, keyEpoch)` -> `"familyId|deviceId|deviceSequence|keyEpoch"`
- **E2E encryption:** AES-256-GCM with X25519 key agreement, HKDF key derivation
- **Key storage:** Android Keystore for session keys, SQLCipher for Room DB encryption
- **Recovery:** BIP39 mnemonic seed phrase (12 words)
- **Nonce:** Random 12 bytes prepended to ciphertext: `nonce(12) || ciphertext || tag`, then Base64
- **AAD:** `CryptoManager.buildPayloadAad(familyId, deviceId, deviceSequence, keyEpoch)` -> `"a|b|c|d"`
- **Hash chain:** `HashChainVerifier.computeHash(prevHash, encryptedPayload)` = `SHA256(hexDecode(prevHash) + base64Decode(encryptedPayload))`
- **FLAG_SECURE** on sensitive screens
- **Key zeroing:** Caller owns the key -- don't zero input parameters in decrypt methods

**Nonce**: Random 12 bytes prepended to ciphertext: `nonce(12) || ciphertext || tag`, then Base64-encoded
## Critical Patterns

**Hash chain**: `HashChainVerifier.computeHash(prevHash, encryptedPayload)` = `SHA256(hexDecode(prevHash) + base64Decode(encryptedPayload))`
**Architecture layers:**
```
UI (Compose screens + ViewModels)
-> StateFlow + collectAsStateWithLifecycle
Domain (use cases, models, repository interfaces)
-> suspend functions
Data (Room DAOs, Retrofit API, repository impls, SyncWorker)
-> Crypto (BouncyCastle + JCA)
```

**Navigation**: Sealed class `Routes` with `data object` entries. `popUpTo` with `inclusive = true` at auth flow transitions.
**Sync backends** (all transfer already-encrypted ops -- zero-knowledge maintained):
- Server relay (default): REST API push/pull
- File export/import: ZIP `.kidsync` bundles via SAF
- WebDAV/NextCloud: OkHttp PROPFIND/MKCOL/PUT/GET, WorkManager periodic
- P2P local: Google Nearby Connections, HMAC-SHA256 handshake

## Examples

Good -- ViewModel with proper state management:
```kotlin
@HiltViewModel
class ExampleViewModel @Inject constructor(
private val useCase: ExampleUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(ExampleUiState())
val state: StateFlow<ExampleUiState> = _state.asStateFlow()

fun onAction() {
viewModelScope.launch {
_state.update { it.copy(loading = true) }
useCase.execute().fold(
onSuccess = { _state.update { s -> s.copy(data = it, loading = false) } },
onFailure = { _state.update { s -> s.copy(error = it.message, loading = false) } },
)
}
}
}
```

## Signing (Release)
Bad -- bypassing repository, direct DAO in ViewModel:
```kotlin
@HiltViewModel
class BadViewModel @Inject constructor(
private val dao: ExampleDao, // Should use repository/use case
) : ViewModel() { /* ... */ }
```

Reads from env vars or `local.properties`:
- `KIDSYNC_KEYSTORE_PATH` / `keystore.path`
- `KIDSYNC_KEYSTORE_PASSWORD` / `keystore.password`
- `KIDSYNC_KEY_ALIAS` / `key.alias`
- `KIDSYNC_KEY_PASSWORD` / `key.password`
## Checklist

Only activates when all 4 values present.
Before committing Android changes:
- [ ] `./gradlew test` passes
- [ ] ViewModels use `@HiltViewModel` + `StateFlow` (not LiveData)
- [ ] Composables use `collectAsStateWithLifecycle` (not `collectAsState`)
- [ ] Security-critical SharedPreferences use `.commit()` (not `.apply()`)
- [ ] Crypto key zeroing: caller owns the key, don't zero input params
- [ ] New screens with sensitive data use `FLAG_SECURE`

## Known Tech Debt

- CalendarViewModel is 743 lines (SRP violation, should split)
- ExpenseViewModel bypasses repository, injects DAO directly
- Monolithic UI state objects (AuthUiState: 16 fields, CalendarUiState: 25 fields)
- In-memory expense filtering instead of SQL WHERE
- No sync status UI indicator (offline/syncing/synced)

## When Stuck

1. Check `di/` modules for what's provided by Hilt (DatabaseModule, NetworkModule, CryptoModule, AppModule)
2. Check `domain/repository/` interfaces for available data operations
3. Check `data/local/dao/` for Room query patterns
4. Check `ui/navigation/Routes.kt` for all navigation destinations
5. Check `crypto/CryptoManager.kt` interface for encryption API
6. Run `./gradlew test` -- failures give clear assertion messages
Loading