Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a317d02
add AOSP dependencies as git submodules
muhomorr Dec 19, 2024
8572783
add Gradle build support
muhomorr Dec 19, 2024
0bc5330
fix androidx.appcompat resource references
muhomorr Dec 19, 2024
843b26c
inline SET_DEFAULT_ACCOUNT_FOR_CONTACTS permission system API constant
muhomorr Dec 19, 2024
eef36f1
add assets dir to Gradle sourceSets
muhomorr Dec 20, 2024
352c581
update platform_external_libphonenumber
muhomorr Dec 20, 2024
a2d1a1d
Remove .idea from git
RankoR Apr 10, 2026
9250aab
Update Android SDK
RankoR Apr 10, 2026
e6d5ba3
Remove system APIs calls from tests
RankoR Apr 10, 2026
8bd1396
Migrate to version catalogs and Gradle KTS
RankoR Apr 10, 2026
d026384
Upgrade dependencies
RankoR Apr 10, 2026
6b30384
Upgrade gradle
RankoR Apr 10, 2026
be65fca
Upgrade AGP to 9.1.0
RankoR Apr 10, 2026
4612910
Fix status bar color
RankoR Apr 10, 2026
9daf54e
Add coroutine dependencies
RankoR Apr 10, 2026
4c3938f
Add Hilt
RankoR Apr 10, 2026
cd0e547
Add Compose dependencies
RankoR Apr 10, 2026
23e722d
Add tests dependencies
RankoR Apr 10, 2026
1735cb5
Add ktlint, detekt; Disable linter for AOSP libs; fix linter errors
RankoR Apr 10, 2026
1b17085
Add verification-metadata.xml
RankoR Apr 10, 2026
20a75d0
Improve dependencies ordering
RankoR Apr 10, 2026
8f1a80b
Add GitHub Actions workflows
RankoR Apr 10, 2026
0eacf19
Add minimal README.md
RankoR Apr 10, 2026
eaf6044
chore: add project setup — CLAUDE.md, skills, plan, brainstorm, fix m…
nrobi144 Apr 14, 2026
3470fe1
feat(contacts): Phase 1 — contact creation screen with name/phone/ema…
nrobi144 Apr 14, 2026
3f81694
feat(contacts): Phase 2 — all 13 field types with full parity
nrobi144 Apr 14, 2026
97a8e69
feat(contacts): Phase 3 — photo support with Coil, gallery, camera
nrobi144 Apr 14, 2026
93cf1f3
feat(contacts): Phase 4 — M3 Expressive polish, edge cases, predictiv…
nrobi144 Apr 14, 2026
b74b790
test(contacts): Phase 5 — test hardening, 220 tests total
nrobi144 Apr 14, 2026
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
227 changes: 227 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# GrapheneOS Contacts — Compose Rewrite

## Build Commands

```bash
./gradlew build # Full build (includes ktlint + detekt)
./gradlew test # Unit tests (Robolectric)
./gradlew connectedAndroidTest # Instrumented/Compose UI tests
./gradlew app:ktlintCheck # Kotlin lint check
./gradlew app:ktlintFormat # Kotlin lint auto-fix
./gradlew app:detekt # Static analysis
```

## Development Workflow: Spec-Driven Development (SDD)

**Every feature follows this strict order:**

```
1. SPEC — Read the plan phase requirements
2. TYPES — Define interfaces, data classes, sealed types (the contract)
3. STUBS — Create source files with TODO() bodies + fake implementations
4. TEST — Write ALL tests. They compile against stubs but FAIL (red)
5. IMPL — Write minimum implementation to make tests pass (green)
6. LINT — ./gradlew app:ktlintFormat && ./gradlew build
```

### Rules
- **Never write implementation before tests.** The plan IS the spec.
- **Tests define the contract.** If it's not tested, it's not a requirement.
- **Minimum implementation.** Write the simplest code that makes tests pass. Refactor after green.
- **Each phase produces**: failing tests first → then passing implementation → then green build.
- **Test files are created BEFORE source files** for each new component.

### SDD per component type

| Component | Write first (red) | Then implement (green) |
|-----------|-------------------|----------------------|
| Mapper | `RawContactDeltaMapperTest.kt` | `RawContactDeltaMapper.kt` |
| ViewModel | `ContactCreationViewModelTest.kt` | `ContactCreationViewModel.kt` |
| Delegate | `ContactFieldsDelegateTest.kt` | `ContactFieldsDelegate.kt` |
| UI Screen | `ContactCreationEditorScreenTest.kt` | `ContactCreationEditorScreen.kt` |
| UI Section | `PhoneSectionTest.kt` | `PhoneSection.kt` |

### What "test first" means concretely

```kotlin
// 1. Write this FIRST — it won't compile yet
class RawContactDeltaMapperTest {
@Test fun mapsName_toStructuredNameDelta() { ... }
@Test fun emptyPhone_notIncluded() { ... }
@Test fun customLabel_setsBothTypeAndLabel() { ... }
}

// 2. Create stub class — just enough to compile
class RawContactDeltaMapper @Inject constructor() {
fun map(uiState: ContactCreationUiState, account: AccountWithDataSet?): DeltaMapperResult =
TODO("Not yet implemented")
}

// 3. Run tests — they fail (red). Good.
// 4. Implement map() — tests pass (green). Done.
```

## Architecture

### Pattern: State-down, Events-up MVI

```
Activity (@AndroidEntryPoint)
└─ setContent { AppTheme { Screen(uiState, onAction) } }

Screen composable receives:
- uiState: UiState (data class with List fields, @Parcelize for SavedStateHandle)
- onAction: (Action) -> Unit (event callback)

ViewModel (@HiltViewModel):
- Single source of truth for state (MutableStateFlow)
- Dispatches to ContactFieldsDelegate for field CRUD
- Effects via Channel<Effect> collected in LaunchedEffect
- SavedStateHandle for process death persistence

No ScreenModel interface. No Jetpack Navigation. No separate EffectHandler class.
```

### Save Callback Mechanism

```
ContactSaveService (fire-and-forget IntentService)
→ On completion, sends callback Intent to ContactCreationActivity
→ Activity receives via onNewIntent()
→ Routes to viewModel.onSaveResult(success, contactUri)

Key: callbackActivity = ContactCreationActivity::class.java
callbackAction = SAVE_COMPLETED_ACTION (custom constant)
Activity must set android:launchMode="singleTop" for onNewIntent to work
```

### Key Decisions
- **Composables accept `(uiState, onAction)`** — not a ScreenModel interface
- **One delegate** — `ContactFieldsDelegate` for complex field state. Photo/account state lives in ViewModel directly.
- **Effects inline** — `LaunchedEffect` collects from `ViewModel.effects` channel
- **Per-section state slices** — each `LazyListScope` extension receives only its data (e.g., `phones: List<PhoneFieldState>`)
- **Reuse existing Java** — `ContactSaveService`, `RawContactDelta`, `ValuesDelta`, `AccountTypeManager` consumed from Kotlin
- **UUID stable keys** — every repeatable field row has a `val id: String = UUID.randomUUID().toString()`. LazyColumn `key = { it.id }`. Never use list index as key.
- **contentType on items()** — all LazyColumn `items()` calls include `contentType` for Compose recycling

### PersistentList + Parcelize Strategy

`PersistentList` is NOT `Parcelable`. Our approach:
- **Runtime state** uses `PersistentList` in the delegate for efficient structural sharing
- **UiState** (which is `@Immutable @Parcelize`) uses regular `List<T>` for SavedStateHandle compatibility
- **Upcast** at ViewModel boundary: PersistentList IS-A List, assign directly (zero-cost, no `.toList()`)
- **On restore** from SavedStateHandle: call `.toPersistentList()` once to re-enter the PersistentList world
- This avoids custom Parcelers and keeps both concerns clean

### Package Structure

```
src/com/android/contacts/ui/contactcreation/
├── ContactCreationActivity.kt
├── ContactCreationEditorScreen.kt
├── ContactCreationViewModel.kt
├── TestTags.kt
├── model/
│ ├── ContactCreationAction.kt
│ ├── ContactCreationEffect.kt
│ ├── ContactCreationUiState.kt
│ └── NameState.kt # Grouped name fields sub-state
├── delegate/
│ └── ContactFieldsDelegate.kt
├── component/
│ ├── NameSection.kt
│ ├── PhoneSection.kt
│ ├── EmailSection.kt
│ ├── AddressSection.kt
│ ├── OrganizationSection.kt # Org + title (single, not repeatable)
│ ├── MoreFieldsSection.kt # Events, relations, website, note, IM, SIP, nickname
│ ├── GroupSection.kt
│ ├── PhotoSection.kt
│ ├── AccountChip.kt
│ └── FieldType.kt
├── mapper/
│ └── RawContactDeltaMapper.kt
└── di/
└── ContactCreationProvidesModule.kt
```

## Conventions

### Compose
- All composables `internal` visibility
- UiState: `@Immutable @Parcelize` with regular `List<T>` fields (SavedStateHandle compatible)
- Delegate: uses `PersistentList` internally for efficient updates
- UUID as stable key for every repeatable field row — never list index
- `contentType` on all `items()` calls: `items(items = phones, key = { it.id }, contentType = { "phone_field" }) { ... }`
- Use Coil `AsyncImage` for all image loading (never decode bitmaps on main thread)
- Use `animateItem()` on LazyColumn items for add/remove animations
- Respect `isReduceMotionEnabled` — skip spring animations when set

### Coil (Photo Loading)
```kotlin
// Always use AsyncImage — never BitmapFactory or contentResolver on main thread
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photoUri)
.size(288) // 96dp * 3 (xxxhdpi) — downsample to display size
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.contact_photo),
modifier = Modifier.size(96.dp).clip(CircleShape).testTag(TestTags.PHOTO_AVATAR),
)
```

### M3 Expressive
- Use `MaterialTheme` + `MotionScheme.expressive()` (NOT `MaterialExpressiveTheme` — alpha only)
- `LargeTopAppBar` with `exitUntilCollapsedScrollBehavior()`
- Spring-based animations via `spring()` with `DampingRatioLowBouncy` / `StiffnessMediumLow`
- No `ExpressiveTopAppBar` exists — don't search for it

### Testing
- **testTag()** on all interactive elements — zero `onNodeWithText` in tests
- TestTags in `TestTags.kt` — flat constants + helper functions for indexed fields
- UI tests: lambda capture `onAction = { capturedActions.add(it) }` — no MockK in UI tests
- ViewModel tests: fake delegate + Turbine for effects + `MainDispatcherRule`
- Mapper tests: highest priority — test all 13 field types
- Tag naming: `contact_creation_{section}_{element}_{index?}`

### Security (GrapheneOS Context)
- Sanitize all intent extras in `onCreate()` with max-length caps
- Never leak PII in error messages — generic strings only
- Delete photo temp files on discard/cancel (`ViewModel.onCleared()`)
- Photo temp files in `getCacheDir()/contact_photos/` subdirectory only
- Do NOT support `Insert.DATA` (arbitrary ContentValues from external apps)
- Validate `EXTRA_ACCOUNT` / `EXTRA_DATA_SET` against actual writable accounts list

### Intent Extras Sanitization Pattern
```kotlin
// In ContactCreationActivity.onCreate()
private fun sanitizeExtras(intent: Intent): SanitizedExtras {
val maxNameLen = 500
val maxPhoneLen = 100
val maxEmailLen = 320
return SanitizedExtras(
name = intent.getStringExtra(Insert.NAME)?.take(maxNameLen),
phone = intent.getStringExtra(Insert.PHONE)?.take(maxPhoneLen),
email = intent.getStringExtra(Insert.EMAIL)?.take(maxEmailLen),
// ... other known Insert.* constants
// EXPLICITLY IGNORE Insert.DATA — arbitrary ContentValues not supported
)
}
```

### DI (Hilt)
- `@AndroidEntryPoint` on Activity
- `@HiltViewModel` on ViewModel
- `@Inject constructor` on delegate, mapper
- `@Provides` module for `AccountTypeManager` (Java singleton)
- Dispatcher qualifiers: `@DefaultDispatcher`, `@IoDispatcher`, `@MainDispatcher` (existing)

## Reference

- **Plan:** `docs/plans/2026-04-14-feat-contact-creation-compose-rewrite-plan.md`
- **Brainstorm:** `docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md`
- **Reference PR:** [GrapheneOS Messaging PR #101](https://github.com/GrapheneOS/Messaging/pull/101)
- **Existing theme:** `src/com/android/contacts/ui/core/Theme.kt`
- **Save service:** `src/com/android/contacts/ContactSaveService.java:463`
- **Delta model:** `src/com/android/contacts/model/RawContactDelta.java`, `ValuesDelta.java`
49 changes: 49 additions & 0 deletions .claude/skills/android-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Android Build & Lint

Run build, lint, and test commands with structured error parsing.

## Usage

Trigger when: code changes need validation, before commits, after implementation phases.

## Commands

### Full Build (includes ktlint + detekt)
```bash
cd ~/Documents/development/foss/platform_packages_apps_Contacts && ./gradlew build 2>&1
```

### Unit Tests Only (fast — Robolectric)
```bash
cd ~/Documents/development/foss/platform_packages_apps_Contacts && ./gradlew test 2>&1
```

### Compose UI Tests (requires emulator/device)
```bash
cd ~/Documents/development/foss/platform_packages_apps_Contacts && ./gradlew connectedAndroidTest 2>&1
```

### Lint Only
```bash
cd ~/Documents/development/foss/platform_packages_apps_Contacts && ./gradlew app:ktlintCheck app:detekt 2>&1
```

### Auto-fix Lint
```bash
cd ~/Documents/development/foss/platform_packages_apps_Contacts && ./gradlew app:ktlintFormat 2>&1
```

## Error Parsing

When build fails:
1. Look for `> Task :app:compile*` lines — compilation errors
2. Look for `ktlint` violations — format with `ktlintFormat`, then re-check
3. Look for `detekt` findings — fix manually (detekt has no auto-fix)
4. Look for test failures — read the failure message, fix the test or source

## Pre-commit Checklist

Run in order:
1. `./gradlew app:ktlintFormat` — auto-fix formatting
2. `./gradlew build` — verify everything passes
3. If build passes → safe to commit
Loading