diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..c1b053a64 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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 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`) +- **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` 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` 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` diff --git a/.claude/skills/android-build.md b/.claude/skills/android-build.md new file mode 100644 index 000000000..6a9ec267a --- /dev/null +++ b/.claude/skills/android-build.md @@ -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 diff --git a/.claude/skills/compose-screen.md b/.claude/skills/compose-screen.md new file mode 100644 index 000000000..7c59fe597 --- /dev/null +++ b/.claude/skills/compose-screen.md @@ -0,0 +1,124 @@ +# Compose Screen Generator + +Generate a new Compose screen following this project's state-down/events-up MVI pattern. + +## When to Use + +Creating a new screen or major section composable in the contactcreation package. + +## Pattern + +### Screen Composable + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun XxxScreen( + uiState: XxxUiState, + onAction: (XxxAction) -> Unit, + modifier: Modifier = Modifier, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text(stringResource(R.string.xxx_title)) }, + navigationIcon = { + IconButton(onClick = { onAction(XxxAction.NavigateBack) }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + ) { + // Each section gets ONLY its state slice + xxxSection(uiState.sectionData, onAction) + } + } +} +``` + +### LazyListScope Section Extensions + +```kotlin +internal fun LazyListScope.xxxSection( + items: PersistentList, + onAction: (XxxAction) -> Unit, +) { + items( + items = items, + key = { it.id }, // stable UUID, NOT list index + contentType = { "xxx_field" }, + ) { item -> + XxxFieldRow( + state = item, + onValueChanged = { onAction(XxxAction.UpdateXxx(item.id, it)) }, + onDelete = { onAction(XxxAction.RemoveXxx(item.id)) }, + modifier = Modifier + .testTag(TestTags.xxxField(items.indexOf(item))) + .animateItem(), + ) + } + item(key = "xxx_add") { + AddFieldButton( + label = stringResource(R.string.add_xxx), + onClick = { onAction(XxxAction.AddXxx) }, + modifier = Modifier.testTag(TestTags.XXX_ADD), + ) + } +} +``` + +### UiState + +```kotlin +@Parcelize +internal data class XxxUiState( + val items: PersistentList = persistentListOf(XxxFieldState()), + val isLoading: Boolean = false, +) : Parcelable + +@Parcelize +internal data class XxxFieldState( + val id: String = UUID.randomUUID().toString(), + val value: String = "", + val type: XxxType = XxxType.DEFAULT, +) : Parcelable +``` + +### Action / Effect + +```kotlin +internal sealed interface XxxAction { + data object NavigateBack : XxxAction + data object Save : XxxAction + data class UpdateXxx(val id: String, val value: String) : XxxAction + data class RemoveXxx(val id: String) : XxxAction + data object AddXxx : XxxAction +} + +internal sealed interface XxxEffect { + data class Save(val result: DeltaMapperResult) : XxxEffect + data object NavigateBack : XxxEffect + data class ShowError(val messageResId: Int) : XxxEffect +} +``` + +## Checklist + +- [ ] All composables `internal` +- [ ] State class `@Parcelize` +- [ ] `PersistentList` for repeatable fields +- [ ] Stable `key` (UUID) on list items — never list index +- [ ] `contentType` on list items +- [ ] `animateItem()` modifier on items +- [ ] `testTag()` on all interactive elements +- [ ] Section receives only its state slice, not full UiState +- [ ] Strings from `R.string.*`, never hardcoded diff --git a/.claude/skills/compose-test.md b/.claude/skills/compose-test.md new file mode 100644 index 000000000..c85237cd3 --- /dev/null +++ b/.claude/skills/compose-test.md @@ -0,0 +1,191 @@ +# Compose Test Generator + +Generate Compose UI tests using testTag() and lambda capture (no MockK in UI layer). + +## When to Use + +**BEFORE creating or modifying a Compose screen or section component.** We follow Spec-Driven Development: + +1. Read the plan phase requirements — these are the test specs +2. Write ALL tests FIRST — they must fail (red) +3. Create stub source files with `TODO()` — tests compile but fail +4. Implement — tests pass (green) +5. `./gradlew build` — all green + +**Test files are always created before source files.** + +## UI Test Pattern (androidTest) + +```kotlin +class XxxScreenTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + // --- Rendering tests --- + + @Test + fun initialState_showsExpectedFields() { + setContent() + composeTestRule.onNodeWithTag(TestTags.XXX_FIELD).assertIsDisplayed() + } + + @Test + fun emptyState_hidesOptionalSection() { + setContent(state = XxxUiState(optionalItems = persistentListOf())) + composeTestRule.onNodeWithTag(TestTags.OPTIONAL_SECTION).assertDoesNotExist() + } + + // --- Interaction tests --- + + @Test + fun tapSave_dispatchesSaveAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick() + assertEquals(XxxAction.Save, capturedActions.last()) + } + + @Test + fun typeInField_dispatchesUpdateAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.xxxField(0)).performTextInput("hello") + assertIs(capturedActions.last()) + } + + @Test + fun tapAddButton_dispatchesAddAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.XXX_ADD).performClick() + assertEquals(XxxAction.AddXxx, capturedActions.last()) + } + + @Test + fun tapDelete_dispatchesRemoveAction() { + setContent(state = XxxUiState( + items = persistentListOf(XxxFieldState(id = "1"), XxxFieldState(id = "2")) + )) + composeTestRule.onNodeWithTag(TestTags.xxxDelete(1)).performClick() + assertIs(capturedActions.last()) + } + + // --- Disabled state tests --- + + @Test + fun savingState_disablesSaveButton() { + setContent(state = XxxUiState(isSaving = true)) + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).assertIsNotEnabled() + } + + // --- Helper --- + + private fun setContent(state: XxxUiState = XxxUiState()) { + composeTestRule.setContent { + AppTheme { + XxxScreen( + uiState = state, + onAction = { capturedActions.add(it) }, + ) + } + } + } +} +``` + +## ViewModel Test Pattern (test — Robolectric) + +```kotlin +@RunWith(RobolectricTestRunner::class) +class XxxViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun saveAction_emitsSaveEffect() = runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel(initialState = stateWithData()) + vm.effects.test { + vm.onAction(XxxAction.Save) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun addAction_addsEmptyRow() = runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + val initialCount = vm.uiState.value.items.size + vm.onAction(XxxAction.AddXxx) + assertEquals(initialCount + 1, vm.uiState.value.items.size) + } + + @Test + fun removeAction_removesRow() = runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel(initialState = XxxUiState( + items = persistentListOf(XxxFieldState(id = "1"), XxxFieldState(id = "2")) + )) + vm.onAction(XxxAction.RemoveXxx("1")) + assertEquals(1, vm.uiState.value.items.size) + assertEquals("2", vm.uiState.value.items.first().id) + } + + private fun createViewModel( + initialState: XxxUiState = XxxUiState(), + fieldsDelegate: ContactFieldsDelegate = FakeContactFieldsDelegate(), + ): XxxViewModel { + val savedStateHandle = SavedStateHandle(mapOf("state" to initialState)) + return XxxViewModel(savedStateHandle, fieldsDelegate) + } +} +``` + +## Mapper Test Pattern (test — pure JUnit, highest priority) + +```kotlin +class RawContactDeltaMapperTest { + private val mapper = RawContactDeltaMapper() + + @Test + fun mapsFieldType_toCorrectMimeType() { + val state = XxxUiState(/* field data */) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(EXPECTED_MIME_TYPE) + assertNotNull(entries) + assertEquals(expectedValue, entries!![0].getAsString(EXPECTED_COLUMN)) + } + + @Test + fun emptyField_notIncluded() { + val state = XxxUiState(items = persistentListOf(XxxFieldState(value = ""))) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(EXPECTED_MIME_TYPE) + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun customTypeLabel_setsBothTypeAndLabel() { + // TYPE_CUSTOM requires BOTH type column AND label column + val state = XxxUiState(items = persistentListOf( + XxxFieldState(value = "data", type = XxxType.Custom("My Label")) + )) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(EXPECTED_MIME_TYPE)!![0] + assertEquals(TYPE_CUSTOM_VALUE, entry.getAsInteger(TYPE_COLUMN)) + assertEquals("My Label", entry.getAsString(LABEL_COLUMN)) + } +} +``` + +## Rules + +- **NEVER** use `onNodeWithText()` — always `onNodeWithTag()` +- **NEVER** use MockK in UI tests — use lambda capture `onAction = { capturedActions.add(it) }` +- MockK is OK in ViewModel tests for dependencies (not for screenModel/onAction) +- Use Turbine `flow.test { }` for Effect assertions, always call `cancelAndIgnoreRemainingEvents()` +- Use `FakeContactFieldsDelegate` (not mockk) for ViewModel tests +- Mapper tests are highest priority — test ALL 13 field types +- Every `testTag` used in tests must exist in `TestTags.kt` diff --git a/.claude/skills/delta-mapper.md b/.claude/skills/delta-mapper.md new file mode 100644 index 000000000..1ef0d0771 --- /dev/null +++ b/.claude/skills/delta-mapper.md @@ -0,0 +1,116 @@ +# RawContactDelta Mapper + +Build RawContactDeltaList from Compose UiState for ContactSaveService. + +## When to Use + +When modifying the RawContactDeltaMapper or adding new field types to the save flow. + +## Core Concept + +For a NEW contact, the delta is in "insert mode": +- `ValuesDelta.fromAfter(contentValues)` — creates an insert delta (mBefore=null, mAfter=contentValues) +- Assigns a **negative temp ID** via `sNextInsertId--` +- Photos reference the temp ID in the `EXTRA_UPDATED_PHOTOS` bundle + +## Creating a Delta for Each Field Type + +```kotlin +// 1. Create raw contact with account +val rawContact = RawContact().apply { + if (account != null) setAccount(account) else setAccountToLocal() +} +val delta = RawContactDelta(ValuesDelta.fromAfter(rawContact.values)) +val tempId = delta.values.id // negative temp ID + +// 2. Add field entries — each is a ValuesDelta with MIMETYPE set +private inline fun contentValues(mimeType: String, block: ContentValues.() -> Unit) = + ContentValues().apply { put(Data.MIMETYPE, mimeType); block() } + +// Name +delta.addEntry(ValuesDelta.fromAfter(contentValues(StructuredName.CONTENT_ITEM_TYPE) { + put(StructuredName.GIVEN_NAME, firstName) + put(StructuredName.FAMILY_NAME, lastName) + put(StructuredName.PREFIX, prefix) + put(StructuredName.MIDDLE_NAME, middleName) + put(StructuredName.SUFFIX, suffix) +})) + +// Phone (repeatable) +delta.addEntry(ValuesDelta.fromAfter(contentValues(Phone.CONTENT_ITEM_TYPE) { + put(Phone.NUMBER, number) + put(Phone.TYPE, type.rawValue) + if (type is PhoneType.Custom) put(Phone.LABEL, type.label) +})) +``` + +## Complete Column Reference + +| MIME Type | Columns | +|-----------|---------| +| `StructuredName` | `GIVEN_NAME`, `FAMILY_NAME`, `PREFIX`, `MIDDLE_NAME`, `SUFFIX` | +| `Phone` | `NUMBER`, `TYPE`, `LABEL` (if custom) | +| `Email` | `DATA` (= address), `TYPE`, `LABEL` | +| `StructuredPostal` | `STREET`, `CITY`, `REGION`, `POSTCODE`, `COUNTRY`, `TYPE` | +| `Organization` | `COMPANY`, `TITLE` | +| `Note` | `NOTE` | +| `Website` | `URL`, `TYPE` | +| `Event` | `START_DATE`, `TYPE` | +| `Relation` | `NAME`, `TYPE` | +| `Im` | `DATA`, `PROTOCOL` | +| `Nickname` | `NAME` | +| `SipAddress` | `SIP_ADDRESS` | +| `GroupMembership` | `GROUP_ROW_ID` | + +## Custom Type Labels + +When type = TYPE_CUSTOM, you MUST set BOTH columns: +```kotlin +put(Phone.TYPE, Phone.TYPE_CUSTOM) +put(Phone.LABEL, "Custom Label Here") +``` +If you set TYPE_CUSTOM without LABEL, the label displays as empty. + +## Photos + +Photos are NOT in the delta. They go in a separate Bundle: +```kotlin +val updatedPhotos = Bundle() +photoUri?.let { updatedPhotos.putParcelable(tempId.toString(), it) } +``` + +ContactSaveService resolves negative temp IDs to real IDs after insert. + +## Empty Field Handling + +- `RawContactModifier.trimEmpty()` runs INSIDE `ContactSaveService.saveContact()` before building diff +- Empty entries get `markDeleted()` — never persisted +- The mapper should SKIP blank entries to keep `hasPendingChanges()` accurate +- If ALL entries are empty, the entire delta is deleted = no-op save + +## createSaveContactIntent Signature + +```kotlin +ContactSaveService.createSaveContactIntent( + context: Context, + state: RawContactDeltaList, + saveModeExtraKey: String, // key name for callback + saveMode: Int, // SaveMode.CLOSE + isProfile: Boolean, // false + callbackActivity: Class<*>, // ContactCreationActivity::class.java + callbackAction: String, // your custom action string + updatedPhotos: Bundle, // tempId(String) → Uri + joinContactIdExtraKey: String?, // null for new + joinContactId: Long?, // null for new +) +``` + +## Testing the Mapper + +Highest priority tests. Verify: +1. Each of 13 field types maps to correct MIME type + columns +2. Empty fields are excluded +3. Custom type labels set both TYPE and LABEL +4. Photo URI in updatedPhotos bundle with correct temp ID key +5. Account set correctly (or local when null) +6. Multiple repeatable fields produce multiple ValuesDelta entries diff --git a/.claude/skills/hilt-module.md b/.claude/skills/hilt-module.md new file mode 100644 index 000000000..c8e68f5fe --- /dev/null +++ b/.claude/skills/hilt-module.md @@ -0,0 +1,72 @@ +# Hilt Module Generator + +Generate Hilt DI modules following this project's conventions. + +## When to Use + +When adding new injectable dependencies (especially bridging Java singletons to Hilt graph). + +## @Provides Module (for Java singletons, external objects) + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +internal object XxxProvidesModule { + + @Provides + @Singleton + fun provideAccountTypeManager( + @ApplicationContext context: Context, + ): AccountTypeManager = AccountTypeManager.getInstance(context) + + @Provides + fun provideContentResolver( + @ApplicationContext context: Context, + ): ContentResolver = context.contentResolver +} +``` + +## Existing Modules + +### CoreProvidesModule (already exists) +```kotlin +// di/core/CoreProvidesModule.kt +@DefaultDispatcher → Dispatchers.Default +@IoDispatcher → Dispatchers.IO +@MainDispatcher → Dispatchers.Main +``` + +### ContactCreationProvidesModule (to create) +```kotlin +// ui/contactcreation/di/ContactCreationProvidesModule.kt +@Module +@InstallIn(SingletonComponent::class) +internal object ContactCreationProvidesModule { + + @Provides + @Singleton + fun provideAccountTypeManager( + @ApplicationContext context: Context, + ): AccountTypeManager = AccountTypeManager.getInstance(context) +} +``` + +## Qualifier Usage + +```kotlin +class MyClass @Inject constructor( + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) +``` + +## Rules + +- Use `@Provides` for Java singletons and external objects (NOT `@Binds`) +- `@Binds` only when you have a Kotlin interface + implementation pair +- `@InstallIn(SingletonComponent::class)` for app-scoped singletons +- `@InstallIn(ViewModelComponent::class)` if scoped to ViewModel lifecycle +- Module classes are `internal object` (not abstract class) +- Activity must have `@AndroidEntryPoint` +- ViewModel must have `@HiltViewModel` + `@Inject constructor` +- Use `hiltViewModel()` from `androidx.hilt:hilt-navigation-compose` in composables diff --git a/.claude/skills/m3-expressive.md b/.claude/skills/m3-expressive.md new file mode 100644 index 000000000..5bd0de4f8 --- /dev/null +++ b/.claude/skills/m3-expressive.md @@ -0,0 +1,153 @@ +# Material 3 Expressive + +Apply M3 Expressive design patterns in this project. + +## When to Use + +When adding UI components, animations, or theming to Compose screens. + +## Theme Setup + +```kotlin +// In AppTheme (ui/core/Theme.kt) +@Composable +fun AppTheme(content: @Composable () -> Unit) { + val colorScheme = if (isSystemInDarkTheme()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + } + + MaterialTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), // <-- enables spring-based motion + shapes = Shapes, + content = content, + ) +} +``` + +**IMPORTANT:** Do NOT use `MaterialExpressiveTheme` — it's alpha-only and unstable. Use `MaterialTheme` with `MotionScheme.expressive()` parameter. + +## Available Components + +### Stable (use freely) +- `LargeTopAppBar` with `exitUntilCollapsedScrollBehavior()` +- `Scaffold`, `Surface`, `Card` +- `OutlinedTextField`, `TextField` +- `Switch`, `Checkbox`, `RadioButton` +- `AlertDialog` +- `ModalBottomSheet` +- `HorizontalDivider` +- `Icon`, `IconButton`, `TextButton`, `FilledTonalButton` +- `DropdownMenu`, `DropdownMenuItem` +- All Material Icons (`Icons.Filled`, `Icons.Outlined`, `Icons.AutoMirrored`) + +### Does NOT Exist (don't search for these) +- ~~`ExpressiveTopAppBar`~~ — use `LargeTopAppBar` +- ~~`ExpressiveButton`~~ — use standard buttons with spring animations + +### Experimental (use with `@OptIn(ExperimentalMaterial3ExpressiveApi::class)`) +- `FloatingActionButtonMenu` / `ToggleFloatingActionButton` — speed-dial FAB +- `FloatingActionButtonMenuItem` +- Expressive list items +- `AppBarWithSearch` — integrated search in top bar + +## Animation Patterns + +### Spring Animations (default with MotionScheme.expressive()) + +```kotlin +// Spring constants for different feels +spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow) // Gentle bounce +spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) // Smooth, no overshoot +``` + +### animateItem() on LazyColumn (field add/remove) + +```kotlin +LazyColumn { + items(fields, key = { it.id }) { field -> + FieldRow( + modifier = Modifier.animateItem( + fadeInSpec = spring(stiffness = Spring.StiffnessMediumLow), + fadeOutSpec = spring(stiffness = Spring.StiffnessMedium), + ) + ) + } +} +``` + +### AnimatedVisibility (expand/collapse sections) + +```kotlin +AnimatedVisibility( + visible = expanded, + enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn(), + exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + fadeOut(), +) { + MoreFieldsContent() +} +``` + +### Shape Morphing (photo avatar) + +```kotlin +val shape by animateShape( + targetValue = if (pressed) RoundedCornerShape(16.dp) else CircleShape, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), +) +Image( + modifier = Modifier.clip(shape).size(96.dp), + // ... +) +``` + +## Accessibility + +```kotlin +// ALWAYS check before applying spring animations +val reduceMotion = LocalReduceMotion.current +val animSpec = if (reduceMotion) snap() else spring(stiffness = Spring.StiffnessMediumLow) +``` + +## Color & Typography + +```kotlin +// Use M3 color roles, not hardcoded colors +MaterialTheme.colorScheme.primary +MaterialTheme.colorScheme.onSurface +MaterialTheme.colorScheme.onSurfaceVariant +MaterialTheme.colorScheme.outlineVariant +MaterialTheme.colorScheme.surfaceContainerLow + +// Typography +MaterialTheme.typography.headlineMedium // Screen title +MaterialTheme.typography.bodyLarge // Field labels +MaterialTheme.typography.bodyMedium // Field values +MaterialTheme.typography.labelLarge // Section headers +MaterialTheme.typography.labelMedium // Chips, buttons +``` + +## Icon Mapping (from legacy drawable → Material Icons Compose) + +| Field Type | Material Icon | +|-----------|---------------| +| Name | `Icons.Filled.Person` | +| Phone | `Icons.Filled.Phone` | +| Email | `Icons.Filled.Email` | +| Address | `Icons.Filled.Place` | +| Organization | `Icons.Filled.Business` | +| Website | `Icons.Filled.Public` | +| Event | `Icons.Filled.Event` | +| Note | `Icons.Filled.Notes` | +| Relation | `Icons.Filled.People` | +| IM | `Icons.Filled.Message` | +| SIP | `Icons.Filled.DialerSip` | +| Group | `Icons.Filled.Label` | +| Photo | `Icons.Filled.CameraAlt` | +| Add field | `Icons.Filled.Add` | +| Delete field | `Icons.Filled.Close` | +| Back | `Icons.AutoMirrored.Filled.ArrowBack` | +| Expand | `Icons.Filled.ExpandMore` | +| Collapse | `Icons.Filled.ExpandLess` | diff --git a/.claude/skills/sdd-workflow.md b/.claude/skills/sdd-workflow.md new file mode 100644 index 000000000..4c8dceb7c --- /dev/null +++ b/.claude/skills/sdd-workflow.md @@ -0,0 +1,123 @@ +# Spec-Driven Development Workflow + +Enforce test-first development driven by the plan as specification. + +## When to Use + +At the START of every implementation phase. This skill defines the execution order. + +## The Cycle + +``` +PLAN (spec) → TESTS (red) → STUBS (compile) → IMPL (green) → LINT → COMMIT +``` + +### Step 1: Read Spec + +Read the current phase from the plan: +```bash +cat docs/plans/2026-04-14-feat-contact-creation-compose-rewrite-plan.md +``` +Extract: +- Phase deliverables +- SDD order (test-first sequence) +- Files to create +- Success criteria + +### Step 2: Write Tests (Red) + +For each component in the phase, write tests FIRST: + +| Component type | Test file location | Write before | +|---------------|-------------------|--------------| +| Mapper | `app/src/test/.../mapper/RawContactDeltaMapperTest.kt` | `RawContactDeltaMapper.kt` | +| ViewModel | `app/src/test/.../ContactCreationViewModelTest.kt` | `ContactCreationViewModel.kt` | +| Delegate | `app/src/test/.../delegate/ContactFieldsDelegateTest.kt` | `ContactFieldsDelegate.kt` | +| UI Screen | `app/src/androidTest/.../ContactCreationEditorScreenTest.kt` | `ContactCreationEditorScreen.kt` | +| UI Section | `app/src/androidTest/.../component/PhoneSectionTest.kt` | `PhoneSection.kt` | + +Tests reference classes that don't exist yet — they won't compile. + +### Step 3: Create Stubs (Compiles, Fails) + +Create minimal source files with `TODO()` bodies so tests compile: + +```kotlin +// Stub — just enough to compile +class RawContactDeltaMapper @Inject constructor() { + fun map(uiState: ContactCreationUiState, account: AccountWithDataSet?): DeltaMapperResult = + TODO("Phase 1b: implement mapper") +} +``` + +Run tests: they compile but FAIL. This is correct. + +```bash +./gradlew test 2>&1 | tail -20 # Expect failures +``` + +### Step 4: Implement (Green) + +Now write the real implementation. Replace each `TODO()` with working code. + +After each component: +```bash +./gradlew test 2>&1 | grep -E "(PASSED|FAILED|Tests)" +``` + +Continue until ALL tests pass. + +### Step 5: Lint + Build + +```bash +./gradlew app:ktlintFormat && ./gradlew build +``` + +Fix any lint/detekt issues. + +### Step 6: Commit + +``` +feat(contacts): Phase Xb - [description] + +- Tests written first (SDD) +- [key deliverables] +``` + +## Test Priority Order (within a phase) + +1. **Mapper tests** — highest risk, data correctness +2. **Delegate tests** — business logic +3. **ViewModel tests** — state management + effects +4. **UI section tests** — component rendering +5. **Screen tests** — integration of sections + +This order ensures the deepest layers are tested first. Each layer builds on the previous. + +## What Makes a Good SDD Test + +```kotlin +// GOOD — tests the SPEC, not the implementation +@Test fun saveAction_withNoChanges_doesNotEmitSaveEffect() { + // Spec: "Empty form save does nothing" + val vm = createViewModel() + vm.effects.test { + vm.onAction(ContactCreationAction.Save) + expectNoEvents() + } +} + +// BAD — tests implementation details +@Test fun saveAction_callsDelegateGetState() { + // This couples the test to HOW, not WHAT +} +``` + +## Phase Checklist + +Before moving to the next phase: +- [ ] All tests for this phase written +- [ ] All tests pass (`./gradlew test`) +- [ ] `./gradlew build` passes (lint + detekt clean) +- [ ] Phase success criteria met +- [ ] Committed diff --git a/.claude/skills/viewmodel-pattern.md b/.claude/skills/viewmodel-pattern.md new file mode 100644 index 000000000..733a7bbb4 --- /dev/null +++ b/.claude/skills/viewmodel-pattern.md @@ -0,0 +1,239 @@ +# ViewModel Pattern Generator + +Generate @HiltViewModel + Action/Effect/UiState following this project's MVI conventions. + +## When to Use + +Creating a new ViewModel or modifying the existing ContactCreationViewModel. + +## Complete ViewModel Template + +```kotlin +@HiltViewModel +internal class XxxViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val fieldsDelegate: ContactFieldsDelegate, + private val deltaMapper: RawContactDeltaMapper, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val _uiState = MutableStateFlow( + savedStateHandle.get("state") ?: XxxUiState() + ) + val uiState: StateFlow = _uiState.asStateFlow() + + // Derived flows per section — prevents cross-section recomposition + val phones: StateFlow> = _uiState + .map { it.phoneNumbers } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val emails: StateFlow> = _uiState + .map { it.emails } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val nameState: StateFlow = _uiState + .map { it.nameState } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NameState()) + + // Effects — one-shot events (save result, navigation, snackbar) + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + init { + // Persist state to SavedStateHandle on changes + viewModelScope.launch { + _uiState.collect { savedStateHandle["state"] = it } + } + } + + fun onAction(action: XxxAction) { + when (action) { + is XxxAction.Save -> save() + is XxxAction.NavigateBack -> handleBack() + is XxxAction.AddPhone -> updateState { copy(phoneNumbers = phoneNumbers + PhoneFieldState()) } + is XxxAction.RemovePhone -> updateState { + copy(phoneNumbers = phoneNumbers.filterNot { it.id == action.id }) + } + is XxxAction.UpdatePhone -> updateState { + copy(phoneNumbers = phoneNumbers.map { + if (it.id == action.id) it.copy(number = action.value) else it + }) + } + // ... other actions + } + } + + private fun save() { + val state = _uiState.value + if (!state.hasPendingChanges()) return + + viewModelScope.launch(defaultDispatcher) { + updateState { copy(isSaving = true) } + val result = deltaMapper.map(state, state.selectedAccount) + _effects.send(XxxEffect.Save(result)) + } + } + + private fun handleBack() { + viewModelScope.launch { + if (_uiState.value.hasPendingChanges()) { + _effects.send(XxxEffect.ShowDiscardDialog) + } else { + _effects.send(XxxEffect.NavigateBack) + } + } + } + + fun onSaveResult(success: Boolean, contactUri: Uri?) { + viewModelScope.launch { + updateState { copy(isSaving = false) } + if (success) { + _effects.send(XxxEffect.SaveSuccess(contactUri)) + } else { + _effects.send(XxxEffect.ShowError(R.string.save_failed)) + } + } + } + + private inline fun updateState(crossinline transform: XxxUiState.() -> XxxUiState) { + _uiState.update { it.transform() } + } + + override fun onCleared() { + super.onCleared() + // Clean up photo temp files if not saved + cleanupTempPhotos() + } +} +``` + +## UiState Template + +```kotlin +@Immutable +@Parcelize +internal data class XxxUiState( + // Name — grouped sub-state + val nameState: NameState = NameState(), + // Repeatable fields — List (PersistentList IS-A List, zero-cost upcast from delegate) + val phoneNumbers: List = listOf(PhoneFieldState()), + val emails: List = listOf(EmailFieldState()), + val addresses: List = emptyList(), + // ... more fields + // Photo + val photoUri: Uri? = null, + // Account + val selectedAccount: AccountWithDataSet? = null, + val availableAccounts: List = emptyList(), + // UI state + val showAllFields: Boolean = false, + val isSaving: Boolean = false, +) : Parcelable { + fun hasPendingChanges(): Boolean = + nameState.hasData() || + phoneNumbers.any { it.number.isNotBlank() } || + emails.any { it.address.isNotBlank() } || + photoUri != null + // ... check all fields +} + +@Parcelize +internal data class PhoneFieldState( + val id: String = UUID.randomUUID().toString(), + val number: String = "", + val type: PhoneType = PhoneType.Mobile, +) : Parcelable + +@Parcelize +internal data class EmailFieldState( + val id: String = UUID.randomUUID().toString(), + val address: String = "", + val type: EmailType = EmailType.Home, +) : Parcelable +``` + +## Action Template + +```kotlin +internal sealed interface XxxAction { + // Navigation + data object NavigateBack : XxxAction + data object Save : XxxAction + data object ConfirmDiscard : XxxAction + + // Name + data class UpdateFirstName(val value: String) : XxxAction + data class UpdateLastName(val value: String) : XxxAction + // ... other name fields + + // Repeatable fields — Add/Remove/Update pattern + data object AddPhone : XxxAction + data class RemovePhone(val id: String) : XxxAction + data class UpdatePhone(val id: String, val value: String) : XxxAction + data class UpdatePhoneType(val id: String, val type: PhoneType) : XxxAction + // ... same for email, address, etc. + + // Photo + data class SetPhoto(val uri: Uri) : XxxAction + data object RemovePhoto : XxxAction + + // Account + data class SelectAccount(val account: AccountWithDataSet) : XxxAction + + // More fields + data object ToggleMoreFields : XxxAction +} +``` + +## Effect Template + +```kotlin +internal sealed interface XxxEffect { + data class Save(val result: DeltaMapperResult) : XxxEffect + data class SaveSuccess(val contactUri: Uri?) : XxxEffect + data class ShowError(val messageResId: Int) : XxxEffect + data object ShowDiscardDialog : XxxEffect + data object NavigateBack : XxxEffect +} +``` + +## Activity Effect Collection + +```kotlin +// In ContactCreationActivity or a top-level composable +LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is Effect.Save -> { + val intent = ContactSaveService.createSaveContactIntent( + context, effect.result.state, + "saveMode", SaveMode.CLOSE, false, + ContactCreationActivity::class.java, + SAVE_COMPLETED_ACTION, + effect.result.updatedPhotos, null, null, + ) + context.startService(intent) + } + is Effect.SaveSuccess -> (context as? Activity)?.finish() + is Effect.ShowError -> snackbarHostState.showSnackbar(context.getString(effect.messageResId)) + is Effect.ShowDiscardDialog -> showDiscardDialog = true + is Effect.NavigateBack -> (context as? Activity)?.finish() + } + } +} +``` + +## Rules + +- Single `MutableStateFlow` in ViewModel — not per-field flows +- Derived `StateFlow` per section via `.map { }.distinctUntilChanged().stateIn()` (including `nameState`) +- `@Immutable` on UiState, `List` fields (PersistentList IS-A List, zero-cost upcast) +- `PersistentList` used internally in delegate for efficient structural sharing +- UUID as stable ID for each field row +- `SavedStateHandle` for process death — sync via `init { collect {} }` +- Effects via `Channel(BUFFERED)` + `receiveAsFlow()` +- Mapper runs on `@DefaultDispatcher` +- Clean up temp files in `onCleared()` diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..f2003881e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset +ij_kotlin_line_break_after_multiline_when_entry = false +ktlint_code_style = android_studio +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_blank-line-between-when-conditions = disabled +max_line_length = 100 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..81b38f31a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,22 @@ +name: Build application + +on: [pull_request, push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + submodules: true + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 21 + cache: gradle + + - name: Build with Gradle + run: ./gradlew build --no-daemon diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml new file mode 100644 index 000000000..32840b9aa --- /dev/null +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -0,0 +1,11 @@ +name: Validate Gradle Wrapper + +on: [pull_request, push] + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: gradle/actions/wrapper-validation@v6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fbf65d9ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.gradle +/local.properties +/.idea/* +!/.idea/vcs.xml +/.kotlin +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +keystore.properties +*.keystore +local.properties +/lib/build diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..9c342a49f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,16 @@ +[submodule "lib/platform_external_libphonenumber"] + path = lib/platform_external_libphonenumber + url = https://github.com/GrapheneOS/platform_external_libphonenumber + branch = main +[submodule "lib/platform_frameworks_ex"] + path = lib/platform_frameworks_ex + url = https://github.com/GrapheneOS/platform_frameworks_ex + branch = main +[submodule "lib/platform_frameworks_opt_vcard"] + path = lib/platform_frameworks_opt_vcard + url = https://github.com/GrapheneOS/platform_frameworks_opt_vcard + branch = main +[submodule "lib/platform_packages_apps_PhoneCommon"] + path = lib/platform_packages_apps_PhoneCommon + url = https://github.com/GrapheneOS/platform_packages_apps_PhoneCommon + branch = main diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..b589d56e9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..cbcb0e4c6 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,186 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 000000000..c224ad564 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 000000000..f8051a6f9 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 000000000..16660f1d8 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..77ec63edf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 4cff3c527..0b6be5a9f 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -15,14 +15,9 @@ --> - - @@ -394,11 +388,12 @@ - + + android:launchMode="singleTop" + android:theme="@android:style/Theme.Material.Light.NoActionBar"> @@ -410,6 +405,13 @@ + + + + ().configureEach { + setSource(files("../src")) + include("**/*.kt") + include("**/*.kts") + exclude("**/build/**") +} + +android { + compileSdk = 36 + buildToolsVersion = "36.1.0" + + namespace = "com.android.contacts" + + buildFeatures { + compose = true + resValues = true + } + + defaultConfig { + minSdk = 36 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" + val selfPkgName = android.namespace + applicationIdSuffix + resValue("string", "applicationLabel", "Contacts d") + resValue("string", "contacts_file_provider_authority", "$selfPkgName.files") + resValue("string", "contacts_sdn_provider_authority", "$selfPkgName.sdn") + } + } + + sourceSets.getByName("main") { + assets.directories.add("../assets") + manifest.srcFile("../AndroidManifest.xml") + java.directories.add("../src") + java.directories.add("../src-bind") + kotlin.directories.add("../src") + res.directories.add("../res") + } + + lint { + abortOnError = false + disable += setOf("UnusedResources", "UnusedIds") + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.palette) + implementation(libs.androidx.swiperefreshlayout) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + + implementation(libs.coil.compose) + + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + implementation(libs.guava) + + implementation(libs.kotlinx.collections.immutable) + + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.material) + + implementation(project(":lib:platform_external_libphonenumber")) + implementation(project(":lib:platform_frameworks_ex:common")) + implementation(project(":lib:platform_frameworks_opt_vcard")) + implementation(project(":lib:platform_packages_apps_PhoneCommon")) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.junit4) + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.mockk.agent) + testImplementation(libs.mockk.android) + testImplementation(libs.robolectric) + testImplementation(libs.turbine) + + androidTestImplementation(kotlin("test")) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + + androidTestImplementation(libs.hilt.android.testing) + kspAndroidTest(libs.hilt.compiler) + + androidTestImplementation(libs.mockk) + androidTestImplementation(libs.mockk.agent) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.turbine) +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationEditorScreenTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationEditorScreenTest.kt new file mode 100644 index 000000000..77701cde0 --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationEditorScreenTest.kt @@ -0,0 +1,139 @@ +package com.android.contacts.ui.contactcreation + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.ContactCreationUiState +import com.android.contacts.ui.core.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ContactCreationEditorScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun initialState_showsSaveButton() { + setContent() + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).assertIsDisplayed() + } + + @Test + fun initialState_showsBackButton() { + setContent() + composeTestRule.onNodeWithTag(TestTags.BACK_BUTTON).assertIsDisplayed() + } + + @Test + fun initialState_showsNameField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed() + } + + @Test + fun initialState_showsPhoneField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed() + } + + @Test + fun initialState_showsEmailField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.emailField(0)).assertIsDisplayed() + } + + @Test + fun initialState_showsAccountChip() { + setContent() + composeTestRule.onNodeWithTag(TestTags.ACCOUNT_CHIP).assertIsDisplayed() + } + + @Test + fun tapSave_dispatchesSaveAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick() + assertEquals(ContactCreationAction.Save, capturedActions.last()) + } + + @Test + fun tapBack_dispatchesNavigateBackAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.BACK_BUTTON).performClick() + assertEquals(ContactCreationAction.NavigateBack, capturedActions.last()) + } + + @Test + fun savingState_disablesSaveButton() { + setContent(state = ContactCreationUiState(isSaving = true)) + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).assertIsNotEnabled() + } + + @Test + fun notSavingState_enablesSaveButton() { + setContent(state = ContactCreationUiState(isSaving = false)) + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).assertIsEnabled() + } + + // --- Discard dialog --- + + @Test + fun discardDialog_rendersWhenShowDiscardDialogTrue() { + setContent(state = ContactCreationUiState(showDiscardDialog = true)) + composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG).assertIsDisplayed() + } + + @Test + fun discardDialog_notRenderedByDefault() { + setContent() + composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG).assertDoesNotExist() + } + + @Test + fun discardDialog_confirmDispatchesConfirmDiscard() { + setContent(state = ContactCreationUiState(showDiscardDialog = true)) + composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG_CONFIRM).performClick() + assertEquals(ContactCreationAction.ConfirmDiscard, capturedActions.last()) + } + + @Test + fun discardDialog_dismissDispatchesDismissDiscardDialog() { + setContent(state = ContactCreationUiState(showDiscardDialog = true)) + composeTestRule.onNodeWithTag(TestTags.DISCARD_DIALOG_DISMISS).performClick() + assertEquals(ContactCreationAction.DismissDiscardDialog, capturedActions.last()) + } + + // --- More fields toggle --- + + @Test + fun moreFieldsToggle_dispatchesToggleMoreFieldsAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.MORE_FIELDS_TOGGLE).performClick() + assertEquals(ContactCreationAction.ToggleMoreFields, capturedActions.last()) + } + + private fun setContent(state: ContactCreationUiState = ContactCreationUiState()) { + composeTestRule.setContent { + AppTheme { + ContactCreationEditorScreen( + uiState = state, + onAction = { capturedActions.add(it) }, + ) + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/AddressSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/AddressSectionTest.kt new file mode 100644 index 000000000..83e59928c --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/AddressSectionTest.kt @@ -0,0 +1,92 @@ +package com.android.contacts.ui.contactcreation.component + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.AddressFieldState +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AddressSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun rendersAddAddressButton() { + setContent() + composeTestRule.onNodeWithTag(TestTags.ADDRESS_ADD).assertIsDisplayed() + } + + @Test + fun tapAddAddress_dispatchesAddAddressAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.ADDRESS_ADD).performClick() + assertEquals(ContactCreationAction.AddAddress, capturedActions.last()) + } + + @Test + fun rendersAllAddressSubFields() { + val addresses = listOf(AddressFieldState(id = "1")) + setContent(addresses = addresses) + composeTestRule.onNodeWithTag(TestTags.addressStreet(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.addressCity(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.addressRegion(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.addressPostcode(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.addressCountry(0)).assertIsDisplayed() + } + + @Test + fun typeInStreet_dispatchesUpdateAddressStreet() { + val addresses = listOf(AddressFieldState(id = "1")) + setContent(addresses = addresses) + composeTestRule.onNodeWithTag(TestTags.addressStreet(0)).performTextInput("123 Main") + assertIs(capturedActions.last()) + } + + @Test + fun typeInCity_dispatchesUpdateAddressCity() { + val addresses = listOf(AddressFieldState(id = "1")) + setContent(addresses = addresses) + composeTestRule.onNodeWithTag(TestTags.addressCity(0)).performTextInput("Chicago") + assertIs(capturedActions.last()) + } + + @Test + fun tapDeleteAddress_dispatchesRemoveAddressAction() { + val addresses = listOf(AddressFieldState(id = "1")) + setContent(addresses = addresses) + composeTestRule.onNodeWithTag(TestTags.addressDelete(0)).performClick() + assertIs(capturedActions.last()) + } + + private fun setContent(addresses: List = emptyList()) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + addressSection( + addresses = addresses, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/EmailSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/EmailSectionTest.kt new file mode 100644 index 000000000..b67bf09a9 --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/EmailSectionTest.kt @@ -0,0 +1,92 @@ +package com.android.contacts.ui.contactcreation.component + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.EmailFieldState +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class EmailSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun rendersEmailField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.emailField(0)).assertIsDisplayed() + } + + @Test + fun rendersAddEmailButton() { + setContent() + composeTestRule.onNodeWithTag(TestTags.EMAIL_ADD).assertIsDisplayed() + } + + @Test + fun typeInEmail_dispatchesUpdateEmail() { + setContent() + composeTestRule.onNodeWithTag(TestTags.emailField(0)).performTextInput("a@b.com") + assertIs(capturedActions.last()) + } + + @Test + fun tapAddEmail_dispatchesAddEmailAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.EMAIL_ADD).performClick() + assertEquals(ContactCreationAction.AddEmail, capturedActions.last()) + } + + @Test + fun multipleEmails_showsDeleteButtons() { + val emails = listOf( + EmailFieldState(id = "1", address = "a@b.com"), + EmailFieldState(id = "2", address = "c@d.com"), + ) + setContent(emails = emails) + composeTestRule.onNodeWithTag(TestTags.emailDelete(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.emailDelete(1)).assertIsDisplayed() + } + + @Test + fun tapDeleteEmail_dispatchesRemoveEmailAction() { + val emails = listOf( + EmailFieldState(id = "1", address = "a@b.com"), + EmailFieldState(id = "2", address = "c@d.com"), + ) + setContent(emails = emails) + composeTestRule.onNodeWithTag(TestTags.emailDelete(1)).performClick() + assertIs(capturedActions.last()) + } + + private fun setContent(emails: List = listOf(EmailFieldState())) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + emailSection( + emails = emails, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/GroupSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/GroupSectionTest.kt new file mode 100644 index 000000000..57182aa52 --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/GroupSectionTest.kt @@ -0,0 +1,106 @@ +package com.android.contacts.ui.contactcreation.component + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.GroupFieldState +import com.android.contacts.ui.contactcreation.model.GroupInfo +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class GroupSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun noAvailableGroups_sectionNotShown() { + setContent(availableGroups = emptyList()) + composeTestRule.onNodeWithTag(TestTags.GROUP_SECTION).assertDoesNotExist() + } + + @Test + fun availableGroups_showsGroupSection() { + setContent( + availableGroups = listOf(GroupInfo(groupId = 1L, title = "Friends")), + ) + composeTestRule.onNodeWithTag(TestTags.GROUP_SECTION).assertIsDisplayed() + } + + @Test + fun rendersCheckboxForEachGroup() { + setContent( + availableGroups = listOf( + GroupInfo(groupId = 1L, title = "Friends"), + GroupInfo(groupId = 2L, title = "Family"), + ), + ) + composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.groupCheckbox(1)).assertIsDisplayed() + } + + @Test + fun selectedGroup_showsChecked() { + setContent( + availableGroups = listOf(GroupInfo(groupId = 1L, title = "Friends")), + selectedGroups = listOf(GroupFieldState(groupId = 1L, title = "Friends")), + ) + composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).assertIsOn() + } + + @Test + fun unselectedGroup_showsUnchecked() { + setContent( + availableGroups = listOf(GroupInfo(groupId = 1L, title = "Friends")), + selectedGroups = emptyList(), + ) + composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).assertIsOff() + } + + @Test + fun tapCheckbox_dispatchesToggleGroupAction() { + setContent( + availableGroups = listOf(GroupInfo(groupId = 42L, title = "Friends")), + ) + composeTestRule.onNodeWithTag(TestTags.groupCheckbox(0)).performClick() + val action = capturedActions.last() + assertIs(action) + assertEquals(42L, action.groupId) + assertEquals("Friends", action.title) + } + + private fun setContent( + availableGroups: List = emptyList(), + selectedGroups: List = emptyList(), + ) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + groupSection( + availableGroups = availableGroups, + selectedGroups = selectedGroups, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/MoreFieldsSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/MoreFieldsSectionTest.kt new file mode 100644 index 000000000..dca072c76 --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/MoreFieldsSectionTest.kt @@ -0,0 +1,211 @@ +package com.android.contacts.ui.contactcreation.component + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.EventFieldState +import com.android.contacts.ui.contactcreation.model.ImFieldState +import com.android.contacts.ui.contactcreation.model.RelationFieldState +import com.android.contacts.ui.contactcreation.model.WebsiteFieldState +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class MoreFieldsSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun rendersMoreFieldsToggle() { + setContent() + composeTestRule.onNodeWithTag(TestTags.MORE_FIELDS_TOGGLE).assertIsDisplayed() + } + + @Test + fun tapToggle_dispatchesToggleMoreFieldsAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.MORE_FIELDS_TOGGLE).performClick() + assertEquals(ContactCreationAction.ToggleMoreFields, capturedActions.last()) + } + + @Test + fun whenExpanded_showsNicknameField() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.NICKNAME_FIELD).assertIsDisplayed() + } + + @Test + fun whenExpanded_showsNoteField() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.NOTE_FIELD).assertIsDisplayed() + } + + @Test + fun whenExpanded_showsSipField() { + setContent(isExpanded = true, showSipField = true) + composeTestRule.onNodeWithTag(TestTags.SIP_FIELD).assertIsDisplayed() + } + + @Test + fun whenExpanded_hiddenSipField_doesNotShow() { + setContent(isExpanded = true, showSipField = false) + composeTestRule.onNodeWithTag(TestTags.SIP_FIELD).assertDoesNotExist() + } + + @Test + fun typeInNickname_dispatchesUpdateNicknameAction() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.NICKNAME_FIELD).performTextInput("Johnny") + assertIs(capturedActions.last()) + } + + @Test + fun typeInNote_dispatchesUpdateNoteAction() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.NOTE_FIELD).performTextInput("A note") + assertIs(capturedActions.last()) + } + + @Test + fun typeInSip_dispatchesUpdateSipAddressAction() { + setContent(isExpanded = true, showSipField = true) + composeTestRule.onNodeWithTag(TestTags.SIP_FIELD).performTextInput("sip:user@voip") + assertIs(capturedActions.last()) + } + + @Test + fun whenExpanded_showsEventAddButton() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.EVENT_ADD).assertIsDisplayed() + } + + @Test + fun tapAddEvent_dispatchesAddEventAction() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.EVENT_ADD).performClick() + assertEquals(ContactCreationAction.AddEvent, capturedActions.last()) + } + + @Test + fun whenExpanded_showsRelationAddButton() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.RELATION_ADD).assertIsDisplayed() + } + + @Test + fun whenExpanded_showsImAddButton() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.IM_ADD).assertIsDisplayed() + } + + @Test + fun whenExpanded_showsWebsiteAddButton() { + setContent(isExpanded = true) + composeTestRule.onNodeWithTag(TestTags.WEBSITE_ADD).assertIsDisplayed() + } + + @Test + fun eventFieldRendered_whenPresent() { + setContent( + isExpanded = true, + events = listOf(EventFieldState(id = "e1", startDate = "2020-01-01")), + ) + composeTestRule.onNodeWithTag(TestTags.eventField(0)).assertIsDisplayed() + } + + @Test + fun typeInEvent_dispatchesUpdateEventAction() { + setContent( + isExpanded = true, + events = listOf(EventFieldState(id = "e1")), + ) + composeTestRule.onNodeWithTag(TestTags.eventField(0)).performTextInput("2020-01-01") + assertIs(capturedActions.last()) + } + + @Test + fun tapDeleteEvent_dispatchesRemoveEventAction() { + setContent( + isExpanded = true, + events = listOf(EventFieldState(id = "e1")), + ) + composeTestRule.onNodeWithTag(TestTags.eventDelete(0)).performClick() + assertIs(capturedActions.last()) + } + + @Test + fun relationFieldRendered_whenPresent() { + setContent( + isExpanded = true, + relations = listOf(RelationFieldState(id = "r1")), + ) + composeTestRule.onNodeWithTag(TestTags.relationField(0)).assertIsDisplayed() + } + + @Test + fun imFieldRendered_whenPresent() { + setContent( + isExpanded = true, + imAccounts = listOf(ImFieldState(id = "im1")), + ) + composeTestRule.onNodeWithTag(TestTags.imField(0)).assertIsDisplayed() + } + + @Test + fun websiteFieldRendered_whenPresent() { + setContent( + isExpanded = true, + websites = listOf(WebsiteFieldState(id = "w1")), + ) + composeTestRule.onNodeWithTag(TestTags.websiteField(0)).assertIsDisplayed() + } + + @Suppress("LongParameterList") + private fun setContent( + isExpanded: Boolean = false, + events: List = emptyList(), + relations: List = emptyList(), + imAccounts: List = emptyList(), + websites: List = emptyList(), + note: String = "", + nickname: String = "", + sipAddress: String = "", + showSipField: Boolean = true, + ) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + moreFieldsSection( + isExpanded = isExpanded, + events = events, + relations = relations, + imAccounts = imAccounts, + websites = websites, + note = note, + nickname = nickname, + sipAddress = sipAddress, + showSipField = showSipField, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/NameSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/NameSectionTest.kt new file mode 100644 index 000000000..e4b2f570c --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/NameSectionTest.kt @@ -0,0 +1,68 @@ +package com.android.contacts.ui.contactcreation.component + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.NameState +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class NameSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun rendersFirstNameField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed() + } + + @Test + fun rendersLastNameField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.NAME_LAST).assertIsDisplayed() + } + + @Test + fun typeFirstName_dispatchesUpdateFirstName() { + setContent() + composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).performTextInput("John") + assertIs(capturedActions.last()) + } + + @Test + fun typeLastName_dispatchesUpdateLastName() { + setContent() + composeTestRule.onNodeWithTag(TestTags.NAME_LAST).performTextInput("Doe") + assertIs(capturedActions.last()) + } + + private fun setContent(nameState: NameState = NameState()) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + nameSection( + nameState = nameState, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhoneSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhoneSectionTest.kt new file mode 100644 index 000000000..422313ac6 --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhoneSectionTest.kt @@ -0,0 +1,92 @@ +package com.android.contacts.ui.contactcreation.component + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.PhoneFieldState +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class PhoneSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun rendersPhoneField() { + setContent() + composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed() + } + + @Test + fun rendersAddPhoneButton() { + setContent() + composeTestRule.onNodeWithTag(TestTags.PHONE_ADD).assertIsDisplayed() + } + + @Test + fun typeInPhone_dispatchesUpdatePhone() { + setContent() + composeTestRule.onNodeWithTag(TestTags.phoneField(0)).performTextInput("555") + assertIs(capturedActions.last()) + } + + @Test + fun tapAddPhone_dispatchesAddPhoneAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.PHONE_ADD).performClick() + assertEquals(ContactCreationAction.AddPhone, capturedActions.last()) + } + + @Test + fun multiplePhones_showsDeleteButtons() { + val phones = listOf( + PhoneFieldState(id = "1", number = "111"), + PhoneFieldState(id = "2", number = "222"), + ) + setContent(phones = phones) + composeTestRule.onNodeWithTag(TestTags.phoneDelete(0)).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.phoneDelete(1)).assertIsDisplayed() + } + + @Test + fun tapDeletePhone_dispatchesRemovePhoneAction() { + val phones = listOf( + PhoneFieldState(id = "1", number = "111"), + PhoneFieldState(id = "2", number = "222"), + ) + setContent(phones = phones) + composeTestRule.onNodeWithTag(TestTags.phoneDelete(1)).performClick() + assertIs(capturedActions.last()) + } + + private fun setContent(phones: List = listOf(PhoneFieldState())) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + phoneSection( + phones = phones, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhotoSectionTest.kt b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhotoSectionTest.kt new file mode 100644 index 000000000..32102e115 --- /dev/null +++ b/app/src/androidTest/java/com/android/contacts/ui/contactcreation/component/PhotoSectionTest.kt @@ -0,0 +1,103 @@ +package com.android.contacts.ui.contactcreation.component + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.contacts.ui.contactcreation.TestTags +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.core.AppTheme +import kotlin.test.assertIs +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class PhotoSectionTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Before + fun setup() { + capturedActions.clear() + } + + @Test + fun noPhoto_showsPlaceholderIcon() { + setContent(photoUri = null) + composeTestRule.onNodeWithTag(TestTags.PHOTO_PLACEHOLDER_ICON).assertIsDisplayed() + } + + @Test + fun withPhoto_showsAvatar() { + setContent(photoUri = Uri.parse("content://media/external/images/1234")) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).assertIsDisplayed() + } + + @Test + fun tapAvatar_showsDropdownMenu() { + setContent(photoUri = null) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).performClick() + composeTestRule.onNodeWithTag(TestTags.PHOTO_PICK_GALLERY).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.PHOTO_TAKE_CAMERA).assertIsDisplayed() + } + + @Test + fun tapAvatar_withPhoto_showsRemoveOption() { + setContent(photoUri = Uri.parse("content://media/external/images/1234")) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).performClick() + composeTestRule.onNodeWithTag(TestTags.PHOTO_REMOVE).assertIsDisplayed() + } + + @Test + fun tapAvatar_withoutPhoto_noRemoveOption() { + setContent(photoUri = null) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).performClick() + composeTestRule.onNodeWithTag(TestTags.PHOTO_REMOVE).assertDoesNotExist() + } + + @Test + fun tapGallery_dispatchesRequestGalleryEffect() { + setContent(photoUri = null) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).performClick() + composeTestRule.onNodeWithTag(TestTags.PHOTO_PICK_GALLERY).performClick() + assertEquals(1, capturedActions.size) + assertIs(capturedActions.last()) + } + + @Test + fun tapCamera_dispatchesRequestCameraEffect() { + setContent(photoUri = null) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).performClick() + composeTestRule.onNodeWithTag(TestTags.PHOTO_TAKE_CAMERA).performClick() + assertEquals(1, capturedActions.size) + assertIs(capturedActions.last()) + } + + @Test + fun tapRemove_dispatchesRemovePhoto() { + setContent(photoUri = Uri.parse("content://media/external/images/1234")) + composeTestRule.onNodeWithTag(TestTags.PHOTO_AVATAR).performClick() + composeTestRule.onNodeWithTag(TestTags.PHOTO_REMOVE).performClick() + assertEquals(ContactCreationAction.RemovePhoto, capturedActions.last()) + } + + private fun setContent(photoUri: Uri? = null) { + composeTestRule.setContent { + AppTheme { + LazyColumn { + photoSection( + photoUri = photoUri, + onAction = { capturedActions.add(it) }, + ) + } + } + } + } +} diff --git a/app/src/test/java/com/android/contacts/test/MainDispatcherRule.kt b/app/src/test/java/com/android/contacts/test/MainDispatcherRule.kt new file mode 100644 index 000000000..226d83fa9 --- /dev/null +++ b/app/src/test/java/com/android/contacts/test/MainDispatcherRule.kt @@ -0,0 +1,22 @@ +package com.android.contacts.test + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule(val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) : + TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt new file mode 100644 index 000000000..f00449a8c --- /dev/null +++ b/app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt @@ -0,0 +1,458 @@ +package com.android.contacts.ui.contactcreation + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.android.contacts.model.RawContactDelta +import com.android.contacts.test.MainDispatcherRule +import com.android.contacts.ui.contactcreation.delegate.ContactFieldsDelegate +import com.android.contacts.ui.contactcreation.mapper.RawContactDeltaMapper +import com.android.contacts.ui.contactcreation.model.ContactCreationAction +import com.android.contacts.ui.contactcreation.model.ContactCreationEffect +import com.android.contacts.ui.contactcreation.model.ContactCreationUiState +import com.android.contacts.ui.contactcreation.model.NameState +import com.android.contacts.ui.contactcreation.model.PhoneFieldState +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ContactCreationViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun initialState_isDefault() { + val vm = createViewModel() + val state = vm.uiState.value + assertEquals(NameState(), state.nameState) + assertEquals(1, state.phoneNumbers.size) + assertEquals(1, state.emails.size) + assertFalse(state.isSaving) + } + + @Test + fun updateFirstName_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + assertEquals("John", vm.uiState.value.nameState.first) + } + + @Test + fun updateLastName_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateLastName("Doe")) + assertEquals("Doe", vm.uiState.value.nameState.last) + } + + @Test + fun addPhone_addsRow() { + val vm = createViewModel() + val initialCount = vm.uiState.value.phoneNumbers.size + vm.onAction(ContactCreationAction.AddPhone) + assertEquals(initialCount + 1, vm.uiState.value.phoneNumbers.size) + } + + @Test + fun removePhone_removesRow() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.AddPhone) + val id = vm.uiState.value.phoneNumbers[0].id + vm.onAction(ContactCreationAction.RemovePhone(id)) + assertEquals(1, vm.uiState.value.phoneNumbers.size) + assertTrue(vm.uiState.value.phoneNumbers.none { it.id == id }) + } + + @Test + fun updatePhone_updatesValue() { + val vm = createViewModel() + val id = vm.uiState.value.phoneNumbers[0].id + vm.onAction(ContactCreationAction.UpdatePhone(id, "555-1234")) + assertEquals("555-1234", vm.uiState.value.phoneNumbers[0].number) + } + + @Test + fun addEmail_addsRow() { + val vm = createViewModel() + val initialCount = vm.uiState.value.emails.size + vm.onAction(ContactCreationAction.AddEmail) + assertEquals(initialCount + 1, vm.uiState.value.emails.size) + } + + @Test + fun saveAction_withPendingChanges_emitsSaveEffect() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + + vm.effects.test { + vm.onAction(ContactCreationAction.Save) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun saveAction_withNoChanges_doesNotEmitSaveEffect() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.effects.test { + vm.onAction(ContactCreationAction.Save) + expectNoEvents() + } + } + + @Test + fun navigateBack_withNoChanges_emitsNavigateBack() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.effects.test { + vm.onAction(ContactCreationAction.NavigateBack) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun navigateBack_withChanges_setsShowDiscardDialog() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + vm.onAction(ContactCreationAction.NavigateBack) + assertTrue(vm.uiState.value.showDiscardDialog) + } + + @Test + fun navigateBack_withChanges_doesNotEmitNavigateBack() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + + vm.effects.test { + vm.onAction(ContactCreationAction.NavigateBack) + expectNoEvents() + } + } + + @Test + fun dismissDiscardDialog_clearsShowDiscardDialog() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + vm.onAction(ContactCreationAction.NavigateBack) + assertTrue(vm.uiState.value.showDiscardDialog) + + vm.onAction(ContactCreationAction.DismissDiscardDialog) + assertFalse(vm.uiState.value.showDiscardDialog) + } + + @Test + fun confirmDiscard_emitsNavigateBack() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + vm.onAction(ContactCreationAction.NavigateBack) + + vm.effects.test { + vm.onAction(ContactCreationAction.ConfirmDiscard) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun confirmDiscard_clearsShowDiscardDialog() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + vm.onAction(ContactCreationAction.NavigateBack) + assertTrue(vm.uiState.value.showDiscardDialog) + + vm.onAction(ContactCreationAction.ConfirmDiscard) + assertFalse(vm.uiState.value.showDiscardDialog) + } + + // --- Zero-account / local-only --- + + @Test + fun save_withNoAccount_usesLocalAccount() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("Local")) + + vm.effects.test { + vm.onAction(ContactCreationAction.Save) + val effect = awaitItem() + assertIs(effect) + val delta = effect.result.state[0] + assertIs(delta) + // When no account selected, mapper calls setAccountToLocal() + assertNull(vm.uiState.value.selectedAccount) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun onSaveResult_success_emitsSaveSuccess() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + val uri = Uri.parse("content://contacts/1") + + vm.effects.test { + vm.onSaveResult(true, uri) + val effect = awaitItem() + assertIs(effect) + assertEquals(uri, effect.contactUri) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun onSaveResult_failure_emitsShowError() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + + vm.effects.test { + vm.onSaveResult(false, null) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun processDeathRestore_preservesState() { + val savedState = ContactCreationUiState( + nameState = NameState(first = "Saved"), + phoneNumbers = listOf(PhoneFieldState(number = "555")), + ) + val vm = createViewModel(initialState = savedState) + assertEquals("Saved", vm.uiState.value.nameState.first) + assertEquals("555", vm.uiState.value.phoneNumbers[0].number) + } + + // --- Photo --- + + @Test + fun setPhoto_updatesPhotoUri() { + val vm = createViewModel() + val uri = Uri.parse("content://media/external/images/1234") + vm.onAction(ContactCreationAction.SetPhoto(uri)) + assertEquals(uri, vm.uiState.value.photoUri) + } + + @Test + fun removePhoto_clearsPhotoUri() { + val vm = createViewModel() + val uri = Uri.parse("content://media/external/images/1234") + vm.onAction(ContactCreationAction.SetPhoto(uri)) + vm.onAction(ContactCreationAction.RemovePhoto) + assertNull(vm.uiState.value.photoUri) + } + + @Test + fun setPhoto_countsAsPendingChange() { + val vm = createViewModel() + val uri = Uri.parse("content://media/external/images/1234") + vm.onAction(ContactCreationAction.SetPhoto(uri)) + assertTrue(vm.uiState.value.hasPendingChanges()) + } + + @Test + fun saveAction_setsIsSaving() = + runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateFirstName("John")) + vm.effects.test { + vm.onAction(ContactCreationAction.Save) + awaitItem() // Save effect + assertTrue(vm.uiState.value.isSaving) + cancelAndIgnoreRemainingEvents() + } + } + + // --- Process death round-trip --- + + @Test + fun processDeathRestore_preservesAllFieldTypes() { + val savedState = ContactCreationUiState( + nameState = NameState( + prefix = "Dr", + first = "John", + middle = "M", + last = "Doe", + suffix = "Jr", + ), + phoneNumbers = listOf(PhoneFieldState(number = "555")), + emails = listOf( + com.android.contacts.ui.contactcreation.model.EmailFieldState(address = "a@b.com"), + ), + addresses = listOf( + com.android.contacts.ui.contactcreation.model.AddressFieldState( + street = "123 Main", + ), + ), + organization = com.android.contacts.ui.contactcreation.model.OrganizationFieldState( + company = "Acme", + title = "Eng", + ), + events = listOf( + com.android.contacts.ui.contactcreation.model.EventFieldState( + startDate = "1990-01-01", + ), + ), + relations = listOf( + com.android.contacts.ui.contactcreation.model.RelationFieldState(name = "Jane"), + ), + imAccounts = listOf( + com.android.contacts.ui.contactcreation.model.ImFieldState(data = "user@jabber"), + ), + websites = listOf( + com.android.contacts.ui.contactcreation.model.WebsiteFieldState( + url = "https://site.com", + ), + ), + note = "Important", + nickname = "Johnny", + sipAddress = "sip:user@voip.example.com", + photoUri = Uri.parse("content://media/external/images/99"), + isMoreFieldsExpanded = true, + ) + val vm = createViewModel(initialState = savedState) + val restored = vm.uiState.value + + assertEquals("Dr", restored.nameState.prefix) + assertEquals("John", restored.nameState.first) + assertEquals("M", restored.nameState.middle) + assertEquals("Doe", restored.nameState.last) + assertEquals("Jr", restored.nameState.suffix) + assertEquals("555", restored.phoneNumbers[0].number) + assertEquals("a@b.com", restored.emails[0].address) + assertEquals("123 Main", restored.addresses[0].street) + assertEquals("Acme", restored.organization.company) + assertEquals("Eng", restored.organization.title) + assertEquals("1990-01-01", restored.events[0].startDate) + assertEquals("Jane", restored.relations[0].name) + assertEquals("user@jabber", restored.imAccounts[0].data) + assertEquals("https://site.com", restored.websites[0].url) + assertEquals("Important", restored.note) + assertEquals("Johnny", restored.nickname) + assertEquals("sip:user@voip.example.com", restored.sipAddress) + assertEquals(Uri.parse("content://media/external/images/99"), restored.photoUri) + assertTrue(restored.isMoreFieldsExpanded) + } + + // --- ToggleMoreFields --- + + @Test + fun toggleMoreFields_togglesIsMoreFieldsExpanded() { + val vm = createViewModel() + assertFalse(vm.uiState.value.isMoreFieldsExpanded) + vm.onAction(ContactCreationAction.ToggleMoreFields) + assertTrue(vm.uiState.value.isMoreFieldsExpanded) + vm.onAction(ContactCreationAction.ToggleMoreFields) + assertFalse(vm.uiState.value.isMoreFieldsExpanded) + } + + // --- Extended field actions --- + + @Test + fun addAddress_addsRow() { + val vm = createViewModel() + assertTrue(vm.uiState.value.addresses.isEmpty()) + vm.onAction(ContactCreationAction.AddAddress) + assertEquals(1, vm.uiState.value.addresses.size) + } + + @Test + fun addEvent_addsRow() { + val vm = createViewModel() + assertTrue(vm.uiState.value.events.isEmpty()) + vm.onAction(ContactCreationAction.AddEvent) + assertEquals(1, vm.uiState.value.events.size) + } + + @Test + fun updateNote_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateNote("A note")) + assertEquals("A note", vm.uiState.value.note) + } + + @Test + fun updateNickname_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateNickname("Johnny")) + assertEquals("Johnny", vm.uiState.value.nickname) + } + + @Test + fun updateSipAddress_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateSipAddress("sip:user@voip")) + assertEquals("sip:user@voip", vm.uiState.value.sipAddress) + } + + @Test + fun updateCompany_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateCompany("Acme")) + assertEquals("Acme", vm.uiState.value.organization.company) + } + + @Test + fun updateJobTitle_updatesState() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateJobTitle("Engineer")) + assertEquals("Engineer", vm.uiState.value.organization.title) + } + + @Test + fun selectAccount_clearsGroups() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.ToggleGroup(1L, "Friends")) + assertEquals(1, vm.uiState.value.groups.size) + + val account = com.android.contacts.model.account.AccountWithDataSet( + "test", + "com.test", + null, + ) + vm.onAction(ContactCreationAction.SelectAccount(account)) + assertTrue(vm.uiState.value.groups.isEmpty()) + assertEquals(account, vm.uiState.value.selectedAccount) + } + + @Test + fun hasPendingChanges_trueForNote() { + val vm = createViewModel() + vm.onAction(ContactCreationAction.UpdateNote("text")) + assertTrue(vm.uiState.value.hasPendingChanges()) + } + + @Test + fun hasPendingChanges_falseForDefaultState() { + val vm = createViewModel() + assertFalse(vm.uiState.value.hasPendingChanges()) + } + + private fun createViewModel( + initialState: ContactCreationUiState = ContactCreationUiState(), + ): ContactCreationViewModel { + val savedStateHandle = SavedStateHandle( + mapOf(ContactCreationViewModel.STATE_KEY to initialState), + ) + return ContactCreationViewModel( + savedStateHandle = savedStateHandle, + fieldsDelegate = ContactFieldsDelegate(), + deltaMapper = RawContactDeltaMapper(), + defaultDispatcher = mainDispatcherRule.testDispatcher, + appContext = RuntimeEnvironment.getApplication(), + ) + } +} diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/delegate/ContactFieldsDelegateTest.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/delegate/ContactFieldsDelegateTest.kt new file mode 100644 index 000000000..e5431af66 --- /dev/null +++ b/app/src/test/java/com/android/contacts/ui/contactcreation/delegate/ContactFieldsDelegateTest.kt @@ -0,0 +1,386 @@ +package com.android.contacts.ui.contactcreation.delegate + +import com.android.contacts.ui.contactcreation.component.AddressType +import com.android.contacts.ui.contactcreation.component.EventType +import com.android.contacts.ui.contactcreation.component.ImProtocol +import com.android.contacts.ui.contactcreation.component.PhoneType +import com.android.contacts.ui.contactcreation.component.RelationType +import com.android.contacts.ui.contactcreation.component.WebsiteType +import com.android.contacts.ui.contactcreation.model.AddressFieldState +import com.android.contacts.ui.contactcreation.model.EventFieldState +import com.android.contacts.ui.contactcreation.model.ImFieldState +import com.android.contacts.ui.contactcreation.model.RelationFieldState +import com.android.contacts.ui.contactcreation.model.WebsiteFieldState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@Suppress("LargeClass") +class ContactFieldsDelegateTest { + + private lateinit var delegate: ContactFieldsDelegate + + @Before + fun setup() { + delegate = ContactFieldsDelegate() + } + + // --- Phone --- + + @Test + fun initialState_hasOneEmptyPhone() { + val phones = delegate.getPhones() + assertEquals(1, phones.size) + assertTrue(phones[0].number.isEmpty()) + } + + @Test + fun addPhone_addsEmptyRow() { + val phones = delegate.addPhone() + assertEquals(2, phones.size) + assertTrue(phones[1].number.isEmpty()) + } + + @Test + fun removePhone_removesById() { + delegate.addPhone() + val phones = delegate.getPhones() + assertEquals(2, phones.size) + val idToRemove = phones[0].id + + val result = delegate.removePhone(idToRemove) + assertEquals(1, result.size) + assertTrue(result.none { it.id == idToRemove }) + } + + @Test + fun updatePhone_updatesValueById() { + val id = delegate.getPhones()[0].id + val result = delegate.updatePhone(id, "555-1234") + assertEquals("555-1234", result[0].number) + } + + @Test + fun updatePhone_nonExistentId_noChange() { + val result = delegate.updatePhone("nonexistent", "555") + assertEquals(1, result.size) + assertTrue(result[0].number.isEmpty()) + } + + @Test + fun updatePhoneType_changesTypeInState() { + val id = delegate.getPhones()[0].id + val result = delegate.updatePhoneType(id, PhoneType.Work) + assertEquals(PhoneType.Work, result[0].type) + } + + // --- Email --- + + @Test + fun initialState_hasOneEmptyEmail() { + val emails = delegate.getEmails() + assertEquals(1, emails.size) + assertTrue(emails[0].address.isEmpty()) + } + + @Test + fun addEmail_addsEmptyRow() { + val emails = delegate.addEmail() + assertEquals(2, emails.size) + } + + @Test + fun removeEmail_removesById() { + delegate.addEmail() + val id = delegate.getEmails()[0].id + val result = delegate.removeEmail(id) + assertEquals(1, result.size) + assertTrue(result.none { it.id == id }) + } + + @Test + fun updateEmail_updatesValueById() { + val id = delegate.getEmails()[0].id + val result = delegate.updateEmail(id, "a@b.com") + assertEquals("a@b.com", result[0].address) + } + + // --- Address --- + + @Test + fun initialState_hasNoAddresses() { + assertTrue(delegate.getAddresses().isEmpty()) + } + + @Test + fun addAddress_addsEmptyRow() { + val addresses = delegate.addAddress() + assertEquals(1, addresses.size) + assertTrue(addresses[0].street.isEmpty()) + } + + @Test + fun removeAddress_removesById() { + delegate.addAddress() + val id = delegate.getAddresses()[0].id + val result = delegate.removeAddress(id) + assertTrue(result.isEmpty()) + } + + @Test + fun updateAddressStreet_updatesValue() { + delegate.addAddress() + val id = delegate.getAddresses()[0].id + val result = delegate.updateAddressStreet(id, "123 Main St") + assertEquals("123 Main St", result[0].street) + } + + @Test + fun updateAddressCity_updatesValue() { + delegate.addAddress() + val id = delegate.getAddresses()[0].id + val result = delegate.updateAddressCity(id, "Chicago") + assertEquals("Chicago", result[0].city) + } + + @Test + fun updateAddressType_updatesValue() { + delegate.addAddress() + val id = delegate.getAddresses()[0].id + val result = delegate.updateAddressType(id, AddressType.Work) + assertEquals(AddressType.Work, result[0].type) + } + + @Test + fun restoreAddresses_replacesInternalState() { + val restored = listOf(AddressFieldState(street = "Restored St")) + delegate.restoreAddresses(restored) + assertEquals("Restored St", delegate.getAddresses()[0].street) + } + + // --- Event --- + + @Test + fun initialState_hasNoEvents() { + assertTrue(delegate.getEvents().isEmpty()) + } + + @Test + fun addEvent_addsEmptyRow() { + val events = delegate.addEvent() + assertEquals(1, events.size) + assertTrue(events[0].startDate.isEmpty()) + } + + @Test + fun removeEvent_removesById() { + delegate.addEvent() + val id = delegate.getEvents()[0].id + val result = delegate.removeEvent(id) + assertTrue(result.isEmpty()) + } + + @Test + fun updateEvent_updatesValue() { + delegate.addEvent() + val id = delegate.getEvents()[0].id + val result = delegate.updateEvent(id, "1990-01-15") + assertEquals("1990-01-15", result[0].startDate) + } + + @Test + fun updateEventType_updatesValue() { + delegate.addEvent() + val id = delegate.getEvents()[0].id + val result = delegate.updateEventType(id, EventType.Anniversary) + assertEquals(EventType.Anniversary, result[0].type) + } + + // --- Relation --- + + @Test + fun initialState_hasNoRelations() { + assertTrue(delegate.getRelations().isEmpty()) + } + + @Test + fun addRelation_addsEmptyRow() { + val relations = delegate.addRelation() + assertEquals(1, relations.size) + assertTrue(relations[0].name.isEmpty()) + } + + @Test + fun removeRelation_removesById() { + delegate.addRelation() + val id = delegate.getRelations()[0].id + val result = delegate.removeRelation(id) + assertTrue(result.isEmpty()) + } + + @Test + fun updateRelation_updatesValue() { + delegate.addRelation() + val id = delegate.getRelations()[0].id + val result = delegate.updateRelation(id, "Jane") + assertEquals("Jane", result[0].name) + } + + @Test + fun updateRelationType_updatesValue() { + delegate.addRelation() + val id = delegate.getRelations()[0].id + val result = delegate.updateRelationType(id, RelationType.Friend) + assertEquals(RelationType.Friend, result[0].type) + } + + // --- IM --- + + @Test + fun initialState_hasNoImAccounts() { + assertTrue(delegate.getImAccounts().isEmpty()) + } + + @Test + fun addIm_addsEmptyRow() { + val ims = delegate.addIm() + assertEquals(1, ims.size) + assertTrue(ims[0].data.isEmpty()) + } + + @Test + fun removeIm_removesById() { + delegate.addIm() + val id = delegate.getImAccounts()[0].id + val result = delegate.removeIm(id) + assertTrue(result.isEmpty()) + } + + @Test + fun updateIm_updatesValue() { + delegate.addIm() + val id = delegate.getImAccounts()[0].id + val result = delegate.updateIm(id, "user@jabber.org") + assertEquals("user@jabber.org", result[0].data) + } + + @Test + fun updateImProtocol_updatesValue() { + delegate.addIm() + val id = delegate.getImAccounts()[0].id + val result = delegate.updateImProtocol(id, ImProtocol.Skype) + assertEquals(ImProtocol.Skype, result[0].protocol) + } + + // --- Website --- + + @Test + fun initialState_hasNoWebsites() { + assertTrue(delegate.getWebsites().isEmpty()) + } + + @Test + fun addWebsite_addsEmptyRow() { + val websites = delegate.addWebsite() + assertEquals(1, websites.size) + assertTrue(websites[0].url.isEmpty()) + } + + @Test + fun removeWebsite_removesById() { + delegate.addWebsite() + val id = delegate.getWebsites()[0].id + val result = delegate.removeWebsite(id) + assertTrue(result.isEmpty()) + } + + @Test + fun updateWebsite_updatesValue() { + delegate.addWebsite() + val id = delegate.getWebsites()[0].id + val result = delegate.updateWebsite(id, "https://example.com") + assertEquals("https://example.com", result[0].url) + } + + @Test + fun updateWebsiteType_updatesValue() { + delegate.addWebsite() + val id = delegate.getWebsites()[0].id + val result = delegate.updateWebsiteType(id, WebsiteType.Blog) + assertEquals(WebsiteType.Blog, result[0].type) + } + + // --- Group --- + + @Test + fun initialState_hasNoGroups() { + assertTrue(delegate.getGroups().isEmpty()) + } + + @Test + fun toggleGroup_addsGroup() { + val groups = delegate.toggleGroup(42L, "Friends") + assertEquals(1, groups.size) + assertEquals(42L, groups[0].groupId) + assertEquals("Friends", groups[0].title) + } + + @Test + fun toggleGroup_removesIfAlreadySelected() { + delegate.toggleGroup(42L, "Friends") + val groups = delegate.toggleGroup(42L, "Friends") + assertTrue(groups.isEmpty()) + } + + @Test + fun clearGroups_removesAll() { + delegate.toggleGroup(1L, "A") + delegate.toggleGroup(2L, "B") + val groups = delegate.clearGroups() + assertTrue(groups.isEmpty()) + } + + // --- Restore --- + + @Test + fun restorePhones_replacesInternalState() { + val id = delegate.getPhones()[0].id + delegate.updatePhone(id, "old") + + val newPhones = listOf( + com.android.contacts.ui.contactcreation.model.PhoneFieldState(number = "restored"), + ) + delegate.restorePhones(newPhones) + + assertEquals("restored", delegate.getPhones()[0].number) + } + + @Test + fun restoreEvents_replacesInternalState() { + val restored = listOf(EventFieldState(startDate = "2020-01-01")) + delegate.restoreEvents(restored) + assertEquals("2020-01-01", delegate.getEvents()[0].startDate) + } + + @Test + fun restoreRelations_replacesInternalState() { + val restored = listOf(RelationFieldState(name = "Bob")) + delegate.restoreRelations(restored) + assertEquals("Bob", delegate.getRelations()[0].name) + } + + @Test + fun restoreImAccounts_replacesInternalState() { + val restored = listOf(ImFieldState(data = "user@im")) + delegate.restoreImAccounts(restored) + assertEquals("user@im", delegate.getImAccounts()[0].data) + } + + @Test + fun restoreWebsites_replacesInternalState() { + val restored = listOf(WebsiteFieldState(url = "https://restored.com")) + delegate.restoreWebsites(restored) + assertEquals("https://restored.com", delegate.getWebsites()[0].url) + } +} diff --git a/app/src/test/java/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapperTest.kt b/app/src/test/java/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapperTest.kt new file mode 100644 index 000000000..bc4351017 --- /dev/null +++ b/app/src/test/java/com/android/contacts/ui/contactcreation/mapper/RawContactDeltaMapperTest.kt @@ -0,0 +1,990 @@ +package com.android.contacts.ui.contactcreation.mapper + +import android.net.Uri +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Event +import android.provider.ContactsContract.CommonDataKinds.GroupMembership +import android.provider.ContactsContract.CommonDataKinds.Im +import android.provider.ContactsContract.CommonDataKinds.Nickname +import android.provider.ContactsContract.CommonDataKinds.Note +import android.provider.ContactsContract.CommonDataKinds.Organization +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.ContactsContract.CommonDataKinds.Relation +import android.provider.ContactsContract.CommonDataKinds.SipAddress +import android.provider.ContactsContract.CommonDataKinds.StructuredName +import android.provider.ContactsContract.CommonDataKinds.StructuredPostal +import android.provider.ContactsContract.CommonDataKinds.Website +import com.android.contacts.ui.contactcreation.component.AddressType +import com.android.contacts.ui.contactcreation.component.EmailType +import com.android.contacts.ui.contactcreation.component.EventType +import com.android.contacts.ui.contactcreation.component.ImProtocol +import com.android.contacts.ui.contactcreation.component.PhoneType +import com.android.contacts.ui.contactcreation.component.RelationType +import com.android.contacts.ui.contactcreation.component.WebsiteType +import com.android.contacts.ui.contactcreation.model.AddressFieldState +import com.android.contacts.ui.contactcreation.model.ContactCreationUiState +import com.android.contacts.ui.contactcreation.model.EmailFieldState +import com.android.contacts.ui.contactcreation.model.EventFieldState +import com.android.contacts.ui.contactcreation.model.GroupFieldState +import com.android.contacts.ui.contactcreation.model.ImFieldState +import com.android.contacts.ui.contactcreation.model.NameState +import com.android.contacts.ui.contactcreation.model.OrganizationFieldState +import com.android.contacts.ui.contactcreation.model.PhoneFieldState +import com.android.contacts.ui.contactcreation.model.RelationFieldState +import com.android.contacts.ui.contactcreation.model.WebsiteFieldState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class RawContactDeltaMapperTest { + + private val mapper = RawContactDeltaMapper() + + // --- Name --- + + @Test + fun mapsName_toStructuredNameDelta() { + val state = ContactCreationUiState( + nameState = NameState(first = "John", last = "Doe"), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("John", entries[0].getAsString(StructuredName.GIVEN_NAME)) + assertEquals("Doe", entries[0].getAsString(StructuredName.FAMILY_NAME)) + } + + @Test + fun mapsFullName_withAllFields() { + val state = ContactCreationUiState( + nameState = NameState( + prefix = "Dr", + first = "John", + middle = "M", + last = "Doe", + suffix = "Jr", + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE)!![0] + + assertEquals("Dr", entry.getAsString(StructuredName.PREFIX)) + assertEquals("John", entry.getAsString(StructuredName.GIVEN_NAME)) + assertEquals("M", entry.getAsString(StructuredName.MIDDLE_NAME)) + assertEquals("Doe", entry.getAsString(StructuredName.FAMILY_NAME)) + assertEquals("Jr", entry.getAsString(StructuredName.SUFFIX)) + } + + @Test + fun emptyName_notIncluded() { + val state = ContactCreationUiState( + nameState = NameState(), + phoneNumbers = listOf(PhoneFieldState(number = "555")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + // --- Phone --- + + @Test + fun mapsPhone_toPhoneDelta() { + val state = ContactCreationUiState( + phoneNumbers = listOf(PhoneFieldState(number = "555-1234", type = PhoneType.Mobile)), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("555-1234", entries[0].getAsString(Phone.NUMBER)) + assertEquals(Phone.TYPE_MOBILE, entries[0].getAsInteger(Phone.TYPE)) + } + + @Test + fun emptyPhone_notIncluded() { + val state = ContactCreationUiState( + phoneNumbers = listOf(PhoneFieldState(number = "")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun multiplePhones_producesMultipleEntries() { + val state = ContactCreationUiState( + phoneNumbers = listOf( + PhoneFieldState(number = "111"), + PhoneFieldState(number = "222"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + @Test + fun customPhoneType_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + phoneNumbers = listOf( + PhoneFieldState(number = "555", type = PhoneType.Custom("Satellite")), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Phone.TYPE_CUSTOM, entry.getAsInteger(Phone.TYPE)) + assertEquals("Satellite", entry.getAsString(Phone.LABEL)) + } + + // --- Email --- + + @Test + fun mapsEmail_toEmailDelta() { + val state = ContactCreationUiState( + emails = listOf(EmailFieldState(address = "john@example.com", type = EmailType.Home)), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("john@example.com", entries[0].getAsString(Email.DATA)) + assertEquals(Email.TYPE_HOME, entries[0].getAsInteger(Email.TYPE)) + } + + @Test + fun emptyEmail_notIncluded() { + val state = ContactCreationUiState( + emails = listOf(EmailFieldState(address = "")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun customEmailType_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + emails = listOf( + EmailFieldState(address = "a@b.com", type = EmailType.Custom("VIP")), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Email.TYPE_CUSTOM, entry.getAsInteger(Email.TYPE)) + assertEquals("VIP", entry.getAsString(Email.LABEL)) + } + + // --- Address --- + + @Test + fun mapsAddress_toStructuredPostalDelta() { + val state = ContactCreationUiState( + addresses = listOf( + AddressFieldState( + street = "123 Main St", + city = "Springfield", + region = "IL", + postcode = "62701", + country = "US", + type = AddressType.Home, + ), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("123 Main St", entries[0].getAsString(StructuredPostal.STREET)) + assertEquals("Springfield", entries[0].getAsString(StructuredPostal.CITY)) + assertEquals("IL", entries[0].getAsString(StructuredPostal.REGION)) + assertEquals("62701", entries[0].getAsString(StructuredPostal.POSTCODE)) + assertEquals("US", entries[0].getAsString(StructuredPostal.COUNTRY)) + assertEquals(StructuredPostal.TYPE_HOME, entries[0].getAsInteger(StructuredPostal.TYPE)) + } + + @Test + fun emptyAddress_notIncluded() { + val state = ContactCreationUiState( + addresses = listOf(AddressFieldState()), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun addressWithOnlyCityFilled_isIncluded() { + val state = ContactCreationUiState( + addresses = listOf(AddressFieldState(city = "Chicago")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("Chicago", entries[0].getAsString(StructuredPostal.CITY)) + } + + @Test + fun customAddressType_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + addresses = listOf( + AddressFieldState( + street = "1 Elm", + type = AddressType.Custom("Vacation"), + ), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE)!![0] + + assertEquals(StructuredPostal.TYPE_CUSTOM, entry.getAsInteger(StructuredPostal.TYPE)) + assertEquals("Vacation", entry.getAsString(StructuredPostal.LABEL)) + } + + // --- Organization --- + + @Test + fun mapsOrganization_toOrgDelta() { + val state = ContactCreationUiState( + organization = OrganizationFieldState(company = "Acme", title = "Engineer"), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("Acme", entries[0].getAsString(Organization.COMPANY)) + assertEquals("Engineer", entries[0].getAsString(Organization.TITLE)) + } + + @Test + fun emptyOrganization_notIncluded() { + val state = ContactCreationUiState( + organization = OrganizationFieldState(), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun orgWithOnlyCompany_isIncluded() { + val state = ContactCreationUiState( + organization = OrganizationFieldState(company = "Acme"), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("Acme", entries[0].getAsString(Organization.COMPANY)) + } + + // --- Note --- + + @Test + fun mapsNote_toNoteDelta() { + val state = ContactCreationUiState(note = "Important person") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Note.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("Important person", entries[0].getAsString(Note.NOTE)) + } + + @Test + fun emptyNote_notIncluded() { + val state = ContactCreationUiState(note = "") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Note.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + // --- Website --- + + @Test + fun mapsWebsite_toWebsiteDelta() { + val state = ContactCreationUiState( + websites = listOf( + WebsiteFieldState(url = "https://example.com", type = WebsiteType.Homepage), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("https://example.com", entries[0].getAsString(Website.URL)) + assertEquals(Website.TYPE_HOMEPAGE, entries[0].getAsInteger(Website.TYPE)) + } + + @Test + fun emptyWebsite_notIncluded() { + val state = ContactCreationUiState( + websites = listOf(WebsiteFieldState(url = "")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun customWebsiteType_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + websites = listOf( + WebsiteFieldState( + url = "https://blog.example.com", + type = WebsiteType.Custom("Portfolio"), + ), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Website.TYPE_CUSTOM, entry.getAsInteger(Website.TYPE)) + assertEquals("Portfolio", entry.getAsString(Website.LABEL)) + } + + // --- Event --- + + @Test + fun mapsEvent_toEventDelta() { + val state = ContactCreationUiState( + events = listOf( + EventFieldState(startDate = "1990-01-15", type = EventType.Birthday), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("1990-01-15", entries[0].getAsString(Event.START_DATE)) + assertEquals(Event.TYPE_BIRTHDAY, entries[0].getAsInteger(Event.TYPE)) + } + + @Test + fun emptyEvent_notIncluded() { + val state = ContactCreationUiState( + events = listOf(EventFieldState(startDate = "")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun customEventType_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + events = listOf( + EventFieldState( + startDate = "2020-06-01", + type = EventType.Custom("First met"), + ), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Event.TYPE_CUSTOM, entry.getAsInteger(Event.TYPE)) + assertEquals("First met", entry.getAsString(Event.LABEL)) + } + + // --- Relation --- + + @Test + fun mapsRelation_toRelationDelta() { + val state = ContactCreationUiState( + relations = listOf( + RelationFieldState(name = "Jane Doe", type = RelationType.Spouse), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("Jane Doe", entries[0].getAsString(Relation.NAME)) + assertEquals(Relation.TYPE_SPOUSE, entries[0].getAsInteger(Relation.TYPE)) + } + + @Test + fun emptyRelation_notIncluded() { + val state = ContactCreationUiState( + relations = listOf(RelationFieldState(name = "")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun customRelationType_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + relations = listOf( + RelationFieldState( + name = "Bob", + type = RelationType.Custom("Mentor"), + ), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Relation.TYPE_CUSTOM, entry.getAsInteger(Relation.TYPE)) + assertEquals("Mentor", entry.getAsString(Relation.LABEL)) + } + + // --- IM (PROTOCOL + CUSTOM_PROTOCOL, not TYPE + LABEL) --- + + @Test + fun mapsIm_toImDelta_withProtocol() { + val state = ContactCreationUiState( + imAccounts = listOf( + ImFieldState(data = "user@jabber.org", protocol = ImProtocol.Jabber), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("user@jabber.org", entries[0].getAsString(Im.DATA)) + assertEquals(Im.PROTOCOL_JABBER, entries[0].getAsInteger(Im.PROTOCOL)) + } + + @Test + fun emptyIm_notIncluded() { + val state = ContactCreationUiState( + imAccounts = listOf(ImFieldState(data = "")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun customImProtocol_setsProtocolAndCustomProtocol() { + val state = ContactCreationUiState( + imAccounts = listOf( + ImFieldState( + data = "user123", + protocol = ImProtocol.Custom("Matrix"), + ), + ), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Im.PROTOCOL_CUSTOM, entry.getAsInteger(Im.PROTOCOL)) + assertEquals("Matrix", entry.getAsString(Im.CUSTOM_PROTOCOL)) + } + + // --- Nickname --- + + @Test + fun mapsNickname_toNicknameDelta() { + val state = ContactCreationUiState(nickname = "Johnny") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Nickname.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals("Johnny", entries[0].getAsString(Nickname.NAME)) + } + + @Test + fun emptyNickname_notIncluded() { + val state = ContactCreationUiState(nickname = "") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Nickname.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + // --- SIP --- + + @Test + fun mapsSipAddress_toSipDelta() { + val state = ContactCreationUiState(sipAddress = "sip:user@voip.example.com") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(SipAddress.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals( + "sip:user@voip.example.com", + entries[0].getAsString(SipAddress.SIP_ADDRESS), + ) + } + + @Test + fun emptySipAddress_notIncluded() { + val state = ContactCreationUiState(sipAddress = "") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(SipAddress.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + // --- Group Membership --- + + @Test + fun mapsGroup_toGroupMembershipDelta() { + val state = ContactCreationUiState( + groups = listOf(GroupFieldState(groupId = 42L, title = "Friends")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(GroupMembership.CONTENT_ITEM_TYPE) + + assertNotNull(entries) + assertEquals(1, entries!!.size) + assertEquals(42L, entries[0].getAsLong(GroupMembership.GROUP_ROW_ID)) + } + + @Test + fun multipleGroups_producesMultipleEntries() { + val state = ContactCreationUiState( + groups = listOf( + GroupFieldState(groupId = 1L, title = "Friends"), + GroupFieldState(groupId = 2L, title = "Family"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(GroupMembership.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + // --- Account --- + + @Test + fun nullAccount_setsLocalAccount() { + val state = ContactCreationUiState( + nameState = NameState(first = "Test"), + ) + val result = mapper.map(state, account = null) + + assertNull(result.state[0].values.getAsString("account_name")) + } + + // --- Mixed fields --- + + @Test + fun mixedEmptyAndFilledFields_onlyMapsFilledOnes() { + val state = ContactCreationUiState( + phoneNumbers = listOf( + PhoneFieldState(number = ""), + PhoneFieldState(number = "555"), + PhoneFieldState(number = " "), + ), + emails = listOf( + EmailFieldState(address = ""), + EmailFieldState(address = "a@b.com"), + ), + ) + val result = mapper.map(state, account = null) + + assertEquals(1, result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!!.size) + assertEquals(1, result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)!!.size) + } + + // --- Photo --- + + @Test + fun photoUri_inUpdatedPhotosBundle() { + val photoUri = Uri.parse("content://media/external/images/1234") + val state = ContactCreationUiState( + nameState = NameState(first = "Photo"), + photoUri = photoUri, + ) + val result = mapper.map(state, account = null) + val tempId = result.state[0].values.id.toString() + val bundledUri = result.updatedPhotos.getParcelable(tempId) + + assertEquals(photoUri, bundledUri) + } + + @Test + fun nullPhotoUri_emptyUpdatedPhotosBundle() { + val state = ContactCreationUiState( + nameState = NameState(first = "NoPhoto"), + photoUri = null, + ) + val result = mapper.map(state, account = null) + + assertTrue(result.updatedPhotos.isEmpty) + } + + @Test + fun multipleAddresses_producesMultipleEntries() { + val state = ContactCreationUiState( + addresses = listOf( + AddressFieldState(street = "1 First St"), + AddressFieldState(city = "Second City"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + // --- Multiple entries for repeatable fields --- + + @Test + fun multipleEmails_producesMultipleEntries() { + val state = ContactCreationUiState( + emails = listOf( + EmailFieldState(address = "a@b.com"), + EmailFieldState(address = "c@d.com"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + @Test + fun multipleEvents_producesMultipleEntries() { + val state = ContactCreationUiState( + events = listOf( + EventFieldState(startDate = "2020-01-01"), + EventFieldState(startDate = "2021-06-15"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + @Test + fun multipleRelations_producesMultipleEntries() { + val state = ContactCreationUiState( + relations = listOf( + RelationFieldState(name = "Jane"), + RelationFieldState(name = "Bob"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + @Test + fun multipleImAccounts_producesMultipleEntries() { + val state = ContactCreationUiState( + imAccounts = listOf( + ImFieldState(data = "user1@jabber.org"), + ImFieldState(data = "user2@jabber.org"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + @Test + fun multipleWebsites_producesMultipleEntries() { + val state = ContactCreationUiState( + websites = listOf( + WebsiteFieldState(url = "https://one.com"), + WebsiteFieldState(url = "https://two.com"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE) + + assertEquals(2, entries!!.size) + } + + // --- Non-custom types do NOT set LABEL column --- + + @Test + fun nonCustomPhoneType_doesNotSetLabel() { + val state = ContactCreationUiState( + phoneNumbers = listOf(PhoneFieldState(number = "555", type = PhoneType.Home)), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Phone.TYPE_HOME, entry.getAsInteger(Phone.TYPE)) + assertNull(entry.getAsString(Phone.LABEL)) + } + + @Test + fun nonCustomEmailType_doesNotSetLabel() { + val state = ContactCreationUiState( + emails = listOf(EmailFieldState(address = "a@b.com", type = EmailType.Work)), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Email.TYPE_WORK, entry.getAsInteger(Email.TYPE)) + assertNull(entry.getAsString(Email.LABEL)) + } + + @Test + fun nonCustomImProtocol_doesNotSetCustomProtocol() { + val state = ContactCreationUiState( + imAccounts = listOf(ImFieldState(data = "user", protocol = ImProtocol.Skype)), + ) + val result = mapper.map(state, account = null) + val entry = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE)!![0] + + assertEquals(Im.PROTOCOL_SKYPE, entry.getAsInteger(Im.PROTOCOL)) + assertNull(entry.getAsString(Im.CUSTOM_PROTOCOL)) + } + + // --- Temp ID is negative --- + + @Test + fun tempId_isNegative() { + val state = ContactCreationUiState( + nameState = NameState(first = "Test"), + ) + val result = mapper.map(state, account = null) + val tempId = result.state[0].values.id + + assertTrue("Temp ID should be negative, was $tempId", tempId < 0) + } + + // --- Whitespace-only fields treated as blank --- + + @Test + fun whitespaceOnlyPhone_notIncluded() { + val state = ContactCreationUiState( + phoneNumbers = listOf(PhoneFieldState(number = " \t ")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyEmail_notIncluded() { + val state = ContactCreationUiState( + emails = listOf(EmailFieldState(address = " ")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Email.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyNote_notIncluded() { + val state = ContactCreationUiState(note = " \n ") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Note.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyNickname_notIncluded() { + val state = ContactCreationUiState(nickname = " ") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Nickname.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlySipAddress_notIncluded() { + val state = ContactCreationUiState(sipAddress = " \t ") + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(SipAddress.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyName_notIncluded() { + val state = ContactCreationUiState( + nameState = NameState(first = " ", last = " \t"), + phoneNumbers = listOf(PhoneFieldState(number = "555")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyWebsite_notIncluded() { + val state = ContactCreationUiState( + websites = listOf(WebsiteFieldState(url = " ")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyEvent_notIncluded() { + val state = ContactCreationUiState( + events = listOf(EventFieldState(startDate = " ")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyRelation_notIncluded() { + val state = ContactCreationUiState( + relations = listOf(RelationFieldState(name = " ")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyIm_notIncluded() { + val state = ContactCreationUiState( + imAccounts = listOf(ImFieldState(data = " ")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyAddress_notIncluded() { + val state = ContactCreationUiState( + addresses = listOf(AddressFieldState(street = " ", city = " \t")), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + @Test + fun whitespaceOnlyOrganization_notIncluded() { + val state = ContactCreationUiState( + organization = OrganizationFieldState(company = " ", title = " \t"), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Organization.CONTENT_ITEM_TYPE) + + assertTrue(entries.isNullOrEmpty()) + } + + // --- Mixed blank/populated repeatable fields (only populated saved) --- + + @Test + fun mixedBlankAndPopulatedEvents_onlyMapsPopulated() { + val state = ContactCreationUiState( + events = listOf( + EventFieldState(startDate = ""), + EventFieldState(startDate = "2020-01-01"), + EventFieldState(startDate = " "), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Event.CONTENT_ITEM_TYPE) + + assertEquals(1, entries!!.size) + assertEquals("2020-01-01", entries[0].getAsString(Event.START_DATE)) + } + + @Test + fun mixedBlankAndPopulatedRelations_onlyMapsPopulated() { + val state = ContactCreationUiState( + relations = listOf( + RelationFieldState(name = ""), + RelationFieldState(name = "Jane"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Relation.CONTENT_ITEM_TYPE) + + assertEquals(1, entries!!.size) + assertEquals("Jane", entries[0].getAsString(Relation.NAME)) + } + + @Test + fun mixedBlankAndPopulatedWebsites_onlyMapsPopulated() { + val state = ContactCreationUiState( + websites = listOf( + WebsiteFieldState(url = ""), + WebsiteFieldState(url = "https://site.com"), + WebsiteFieldState(url = " "), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Website.CONTENT_ITEM_TYPE) + + assertEquals(1, entries!!.size) + assertEquals("https://site.com", entries[0].getAsString(Website.URL)) + } + + @Test + fun mixedBlankAndPopulatedIms_onlyMapsPopulated() { + val state = ContactCreationUiState( + imAccounts = listOf( + ImFieldState(data = ""), + ImFieldState(data = "user@jabber"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(Im.CONTENT_ITEM_TYPE) + + assertEquals(1, entries!!.size) + assertEquals("user@jabber", entries[0].getAsString(Im.DATA)) + } + + @Test + fun mixedBlankAndPopulatedAddresses_onlyMapsPopulated() { + val state = ContactCreationUiState( + addresses = listOf( + AddressFieldState(), + AddressFieldState(street = "123 Main"), + ), + ) + val result = mapper.map(state, account = null) + val entries = result.state[0].getMimeEntries(StructuredPostal.CONTENT_ITEM_TYPE) + + assertEquals(1, entries!!.size) + assertEquals("123 Main", entries[0].getAsString(StructuredPostal.STREET)) + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 000000000..3f67ea5ac --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..11198da13 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,65 @@ +import com.android.build.api.dsl.LibraryExtension +import org.jlleitschuh.gradle.ktlint.KtlintExtension + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.ktlint) +} + +buildscript { + dependencies { + classpath(libs.hilt.gradle.plugin) + classpath(libs.kotlin.gradle.plugin) + classpath(libs.ksp.gradle.plugin) + } +} + +val ktlintCliVersion: String = + the() + .named("libs") + .findVersion("ktlint") + .get() + .requiredVersion + +configure { + version.set(ktlintCliVersion) + + filter { + exclude("**/build/**") + } +} + +subprojects { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + plugins.withId("com.android.library") { + // Without this, external (AOSP) libs are getting minSdk = 1 during build + extensions.configure("android") { + defaultConfig { + minSdk = 35 + } + } + + // Without this, linter fails for the external (AOSP) libs + if (path.startsWith(":lib:")) { + tasks.configureEach { + if (name.startsWith("lint")) { + enabled = false + } + } + } + } + + configure { + version.set(ktlintCliVersion) + + filter { + exclude("**/build/**") + } + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 000000000..0aff4317a --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,25 @@ +config: + validation: true + warningsAsErrors: true + +complexity: + LongParameterList: + ignoreDefaultParameters: true + TooManyFunctions: + ignoreAnnotatedFunctions: + - Preview + +naming: + FunctionNaming: + ignoreAnnotated: + - Composable + +style: + MagicNumber: + ignoreCompanionObjectPropertyDeclaration: true + ignorePropertyDeclaration: true + ignoreAnnotated: + - Composable + UnusedPrivateFunction: + ignoreAnnotated: + - Preview diff --git a/docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md b/docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md new file mode 100644 index 000000000..aea14d4bc --- /dev/null +++ b/docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md @@ -0,0 +1,124 @@ +# Brainstorm: Contact Creation Screen — Kotlin/Compose Rewrite + +**Date:** 2026-04-14 +**Status:** Ready for planning + +## What We're Building + +Rewrite the contact creation screen from Java/XML (1892-line `ContactEditorFragment` + 30 supporting classes) to Kotlin + Jetpack Compose with Material 3 Expressive. Tests use stable `testTag()` IDs exclusively. + +**Scope:** Create-only flow. The existing edit/update flows via `ContactEditorActivity` remain untouched. + +**Fields:** Full parity with current editor — name, phone(s), email(s), photo, organization, address, notes, website, events, relations, IM, nickname, groups, custom fields, SIP. + +## Why This Approach + +The GrapheneOS Messaging app has established patterns (PR #101) for Java/XML → Kotlin/Compose migrations. We follow those conventions for consistency across the GrapheneOS app suite, adapting where the Contacts domain differs. + +## Key Decisions + +### Architecture +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Navigation | AnimatedContent + sealed routes | Match Messaging PR pattern; future-proofs for edit/detail screens | +| State management | ScreenModel interface → ViewModel → Delegates | Messaging PR pattern; testable, separates concerns | +| Data persistence | Reuse existing `ContactSaveService` | Battle-tested save path; avoids duplicating ContentProviderOperation logic | +| DI | Hilt (@Binds modules) | Already set up in build.gradle.kts; matches Messaging | +| Testing | `testTag()` on all interactive elements | Task requirement: no text reliance. Constants object for tag IDs | +| Material | M3 Expressive (full) | Use ExpressiveTopAppBar, animated buttons, shape morphing, spring motion | +| Activity | New `ContactCreationActivity` (ComponentActivity) | Hosts Compose content; keeps existing editor untouched | + +### Package Structure +``` +com.android.contacts.ui.contactcreation/ + ContactCreationActivity.kt + common/ + ContactFieldComponents.kt # Reusable field editors (phone row, email row, etc.) + TestTags.kt # All testTag constants + screen/ + ContactCreationScreen.kt # NavHost + AnimatedContent routing + ContactCreationViewModel.kt # ScreenModel impl + ContactCreationEffectHandler.kt # Side effects (save, photo pick, finish) + model/ + ContactCreationAction.kt # Sealed interface + ContactCreationNavRoute.kt # Sealed interface with depth + ContactCreationEffect.kt # Sealed interface + ContactCreationUiState.kt # @Immutable data class + delegate/ + ContactFieldsDelegate.kt # Manages field state (add/remove/edit rows) + PhotoDelegate.kt # Photo selection state + AccountDelegate.kt # Account type selection + mapper/ + ContactCreationUiStateMapper.kt # Maps delegate states → UiState + RawContactDeltaMapper.kt # Maps UiState → RawContactDeltaList for save + di/ + ContactCreationModule.kt # Hilt @Binds module +``` + +### State Model (sketch) +```kotlin +@Immutable +data class ContactCreationUiState( + // Name + val prefix: String = "", + val firstName: String = "", + val middleName: String = "", + val lastName: String = "", + val suffix: String = "", + // Photo + val photoUri: Uri? = null, + // Repeatable fields + val phoneNumbers: List = listOf(PhoneFieldState()), + val emails: List = listOf(EmailFieldState()), + val addresses: List = emptyList(), + val events: List = emptyList(), + val ims: List = emptyList(), + val relations: List = emptyList(), + val websites: List = emptyList(), + // Single fields + val organization: String = "", + val title: String = "", + val nickname: String = "", + val notes: String = "", + val sipAddress: String = "", + // Groups + val groups: List = emptyList(), + // UI state + val showAllFields: Boolean = false, + val isSaving: Boolean = false, + val selectedAccount: AccountInfo? = null, + val availableAccounts: List = emptyList(), +) +``` + +### Test Strategy +| Layer | Tool | What | +|-------|------|------| +| UI (screen) | Compose test + MockK | Render screen with fake state, assert nodes by testTag, verify actions dispatched | +| ViewModel | JUnit + Turbine + Robolectric | Fake delegates, test action→state and action→effect flows | +| Delegates | JUnit + MockK | Unit test field manipulation logic | +| Mapper | JUnit | RawContactDelta mapping correctness | + +### Skill Suite +| Skill | Purpose | +|-------|---------| +| `android-build` | Run gradle build, ktlint, detekt, tests with error parsing | +| `compose-screen` | Generate Compose screen following Messaging PR patterns | +| `compose-test` | Generate Compose UI tests with testTag pattern | +| `viewmodel-pattern` | Generate ScreenModel/Delegate/Action/Effect/UiState skeleton | +| `hilt-module` | Generate @Module/@Binds boilerplate | + +## Guiding Principle + +**Write new Kotlin/Compose code; don't add tech debt; don't increase risk unnecessarily.** If we're writing new code for this screen, do it properly in Kotlin with modern APIs. But don't rewrite shared dependencies or infrastructure that the rest of the app relies on — that increases blast radius for no gain. + +## Resolved Questions + +1. **Account selection UI** → Inline header chip. Tapping opens bottom sheet with account list. +2. **Photo** → Full photo support (camera + gallery + remove). Use `ActivityResultContracts.PickVisualMedia` for gallery, `TakePicture` for camera. Modern APIs, no permissions needed on 13+. +3. **Field type labels** → Kotlin rewrite. New sealed class/enum for field types with label resolution. The existing `EditorUiUtils` is View-coupled — writing new code anyway, so do it cleanly. Reuse the same string resources. +4. **Manifest registration** → Replace `ACTION_INSERT`. New `ContactCreationActivity` owns contact creation. Old `ContactEditorActivity` keeps `ACTION_EDIT` only. Clean cut, no feature flags. + +## Open Questions + +None — ready for planning. diff --git a/docs/plans/2026-04-14-feat-contact-creation-compose-rewrite-plan.md b/docs/plans/2026-04-14-feat-contact-creation-compose-rewrite-plan.md new file mode 100644 index 000000000..90f81ab0b --- /dev/null +++ b/docs/plans/2026-04-14-feat-contact-creation-compose-rewrite-plan.md @@ -0,0 +1,826 @@ +--- +title: "feat: Rewrite contact creation screen in Kotlin/Compose" +type: feat +status: active +date: 2026-04-14 +deepened: 2026-04-14 +origin: docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md +--- + +# feat: Rewrite Contact Creation Screen in Kotlin/Compose + +## Enhancement Summary + +**Deepened on:** 2026-04-14 (round 1), 2026-04-14 (round 2) +**Agents used:** Architecture strategist, Security sentinel, Performance oracle, Code simplicity reviewer, Best practices researcher, Framework docs researcher, RawContactDelta bridging researcher, SpecFlow analyzer + +### Key Improvements from Deepening + +**Round 1:** +1. **Simplified architecture** — dropped ScreenModel interface, NavRoute, UiStateMapper, EffectHandler class (6 files eliminated, ~25% LOC reduction) +2. **Concrete RawContactDeltaMapper** — full implementation with all 13 field types from source code analysis +3. **Security hardening** — intent extras sanitization, photo temp file cleanup, PII-safe error messages +4. **Performance optimizations** — Coil for async photos, state slices per section, PersistentList for repeatable fields, stable UUIDs as keys +5. **Missing dependencies identified** — Coil, hilt-navigation-compose, kotlinx-collections-immutable +6. **Save callback mechanism defined** — `onNewIntent()` handler for ContactSaveService result +7. **Phases consolidated** — 8 → 6 phases (merged fields+save, merged polish+edge cases) + +**Round 2:** +8. **SDD cycle refined** — added TYPES + STUBS steps before TEST for compile-first stubs +9. **Phase 1a+1b merged** — single Phase 1 with bottom-up SDD order (mapper → delegate → VM → sections → screen) +10. **Test paths fixed** — explicit `app/src/test/` vs `app/src/androidTest/` paths +11. **PersistentList strategy clarified** — `@Immutable` on UiState, zero-cost upcast (PersistentList IS-A List) +12. **Missing acceptance criteria tests added** — type change, account selection, custom label, SIP filtering, name section +13. **M3 Expressive specifics** — named spring constants, `contentType` on items(), reduce-motion guard, icon mapping +14. **IM special handling** — PROTOCOL + CUSTOM_PROTOCOL (not TYPE + LABEL) + +### Development Methodology: Spec-Driven Development (SDD) + +Every phase follows a strict **red → green → refactor** cycle driven by this plan as the spec: + +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` + +Test files are created BEFORE source files. The plan's acceptance criteria ARE the test specifications. + +### Architecture Decision: Simplify vs Match Messaging PR + +Multiple reviewers flagged the Messaging PR #101 patterns (ScreenModel interface, AnimatedContent routing, 3 delegates) as over-engineered for a single-screen form. **Decision: simplify.** Rationale: +- Single screen = no routing needed. Bottom sheets handle pickers. +- State-down/events-up `(uiState, onAction)` is more idiomatic Compose than a ScreenModel interface. +- Photo and account state are trivial (~20 LOC each) — fold into ViewModel. +- If a future edit screen rewrite needs these patterns, extract then. YAGNI now. + +This departs from the brainstorm's "match Messaging pattern" decision. The Messaging PR had multiple screens (Settings → AppSettings → SubscriptionSettings) that justified routing. We don't. + +--- + +## Overview + +Rewrite the contact creation screen from Java/XML (`ContactEditorFragment` — 1892 lines + 30 supporting classes) to Kotlin + Jetpack Compose with Material 3 Expressive. Full field parity. Tests use `testTag()` exclusively. Simplified MVI architecture (ViewModel + single delegate + sealed Actions/Effects). + +## Problem Statement / Motivation + +The current contact editor is a monolithic Java Fragment with tightly coupled custom Views, deprecated APIs (`android.app.Fragment`, `LoaderManager`), and no ViewModel layer. The GrapheneOS project is migrating apps to Kotlin/Compose (Messaging app already done). The Contacts app needs the same treatment, starting with the creation screen. + +## Proposed Solution + +New `ContactCreationActivity` (Compose-based, `@AndroidEntryPoint`) replaces `ACTION_INSERT` handling. Existing `ContactEditorActivity` retains `ACTION_EDIT`. Save path reuses the proven `ContactSaveService` via `RawContactDeltaList` — no changes to data layer. + +## Technical Approach + +### Architecture + +``` +ContactCreationActivity (@AndroidEntryPoint, ComponentActivity) + └─ setContent { AppTheme { ContactCreationEditorScreen(viewModel) } } + +ContactCreationEditorScreen (uiState: UiState, onAction: (Action) -> Unit) + ├─ Scaffold + LargeTopAppBar + MotionScheme.expressive() + ├─ LazyColumn with section-scoped state slices + └─ TopAppBar save action + +ContactCreationViewModel (@HiltViewModel) + ├─ ContactFieldsDelegate (manages all field state — single MutableStateFlow) + ├─ Photo state (Uri? — directly in ViewModel) + ├─ Account state (selection — directly in ViewModel) + ├─ SavedStateHandle (process death persistence via @Parcelize) + └─ Effects via Channel + +RawContactDeltaMapper (@Inject) + └─ Converts UiState → RawContactDeltaList → ContactSaveService +``` + +> **Research insight (Architecture):** Composables accept `(uiState, onAction)` directly instead of a ScreenModel interface. This eliminates a Hilt @Binds module and is more idiomatic Compose. UI tests mock via lambda capture instead of MockK interface. + +> **Research insight (Performance):** Each LazyListScope section receives only its state slice (e.g., `phones: List`) not the full UiState. This prevents unnecessary recomposition when unrelated fields change. + +> **Convention:** All LazyColumn `items()` calls must include `contentType` for Compose recycling: `items(items = phones, key = { it.id }, contentType = { "phone_field" }) { ... }` + +### Package Structure + +``` +src/com/android/contacts/ +├── ui/ +│ ├── core/ +│ │ └── Theme.kt # EXISTS — add MotionScheme.expressive() +│ └── contactcreation/ +│ ├── ContactCreationActivity.kt # @AndroidEntryPoint host +│ ├── ContactCreationEditorScreen.kt # Main editor composable +│ ├── ContactCreationViewModel.kt # @HiltViewModel + state +│ ├── model/ +│ │ ├── ContactCreationAction.kt # Sealed interface +│ │ ├── ContactCreationEffect.kt # Sealed interface +│ │ ├── ContactCreationUiState.kt # @Parcelize data class (List fields) +│ │ └── NameState.kt # @Parcelize sub-state for name fields +│ ├── delegate/ +│ │ └── ContactFieldsDelegate.kt # Field CRUD (PersistentList internally) +│ ├── component/ +│ │ ├── NameSection.kt # Name fields composable +│ │ ├── PhoneSection.kt # Phone fields composable +│ │ ├── EmailSection.kt # Email fields composable +│ │ ├── AddressSection.kt # Address fields +│ │ ├── OrganizationSection.kt # Org + title (single, not repeatable) +│ │ ├── MoreFieldsSection.kt # Events, relations, website, note, IM, SIP, nickname +│ │ ├── GroupSection.kt # Group membership +│ │ ├── PhotoSection.kt # Photo avatar + picker +│ │ ├── AccountChip.kt # Account selection chip + sheet +│ │ └── FieldType.kt # Sealed classes for type labels +│ ├── mapper/ +│ │ └── RawContactDeltaMapper.kt # UiState → RawContactDeltaList +│ ├── TestTags.kt # All testTag constants +│ └── di/ +│ └── ContactCreationProvidesModule.kt # @Provides for AccountTypeManager +├── di/core/ +│ ├── CoreProvidesModule.kt # EXISTS — dispatchers +│ └── Qualifiers.kt # EXISTS +``` + +> **Research insight (Architecture):** Split `ContactFieldComponents.kt` into per-section files from the start. With 13 field types, a single file would exceed 1000 lines. Each `LazyListScope` extension is a natural file boundary. + +> **Research insight (Architecture):** New `ContactCreationProvidesModule` needed to expose `AccountTypeManager` (Java singleton) to Hilt graph via `@Provides`. + +### New Dependencies Required + +| Dependency | Purpose | Version catalog entry | +|------------|---------|----------------------| +| `io.coil-kt.coil3:coil-compose` | Async photo loading (off-thread decode, LRU cache) | `coil-compose` | +| `androidx.hilt:hilt-navigation-compose` | `hiltViewModel()` in composables | `hilt-navigation-compose` | +| `org.jetbrains.kotlinx:kotlinx-collections-immutable` | `PersistentList` for delegate internals | `kotlinx-collections-immutable` | + +> **Research insight (Performance):** Photo display MUST use async image loader. A 12MP camera photo = ~48MB bitmap at full resolution. Without Coil, decoding on main thread causes 200-500ms ANR. Hold only `Uri` in state, never `Bitmap`. + +> **Research insight (Performance):** `PersistentList` gives O(log32 n) structural sharing on updates vs O(n) list copies. Used inside `ContactFieldsDelegate` for efficient field CRUD. + +### PersistentList + @Parcelize Resolution + +`PersistentList` is NOT `Parcelable`. Strategy: +- **UiState** (`@Parcelize` + `@Immutable`) uses regular `List` — SavedStateHandle compatible +- **ContactFieldsDelegate** uses `PersistentList` internally for efficient structural sharing on updates +- **ViewModel** bridges: `PersistentList` IS-A `List`, so assign directly to UiState `List` fields (zero-cost upcast, no `.toList()` needed) +- **On restore** from SavedStateHandle: call `.toPersistentList()` once to re-enter the PersistentList world +- No custom Parcelers. No compatibility hacks. Clean separation. + +### NameState Sub-object + +Name fields grouped into a dedicated data class for clean section-scoped state passing: +```kotlin +@Parcelize +data class NameState( + val prefix: String = "", + val first: String = "", + val middle: String = "", + val last: String = "", + val suffix: String = "", +) : Parcelable { + fun hasData() = prefix.isNotBlank() || first.isNotBlank() || + middle.isNotBlank() || last.isNotBlank() || suffix.isNotBlank() +} +``` +`ContactCreationUiState.nameState: NameState` — passed directly to `nameSection()`. + +### Implementation Phases + +#### Phase 1: Core Fields + Save — End-to-End + +**Goal:** App compiles, new activity launches via `ACTION_INSERT`, create contact with name + phone + email → appears in contacts list. + +**SDD order (bottom-up: mapper → delegate → VM → sections → screen):** +1. Scaffold setup: create stubs for Activity, ViewModel (TODO), UiState, Action, Effect, Screen, TestTags, Hilt module. Add deps to build.gradle.kts + libs.versions.toml. Register activity in manifest. `./gradlew build` to verify compilation. +2. Write `RawContactDeltaMapperTest.kt` — test name/phone/email mapping, empty field exclusion, custom labels. Red. +3. Write `ContactFieldsDelegateTest.kt` — test add/remove/update phone, email, `updatePhoneType_changesTypeInState()`. Red. +4. Write `ContactCreationViewModelTest.kt` — test save action → effect, add phone → state update, process death restore. Red. +5. Write `NameSectionTest.kt`, `PhoneSectionTest.kt`, `EmailSectionTest.kt` — test field rendering + action dispatch. Red. +6. Write `ContactCreationEditorScreenTest.kt` — test empty scaffold renders (SAVE_BUTTON visible, BACK_BUTTON visible), name/phone/email sections visible, save dispatches, `selectAccount_dispatchesAction()`. Red. +7. Implement: FieldType → Mapper → Delegate → ViewModel → Sections → Screen wiring → Activity. Green. +8. `./gradlew build` + +**Deliverables:** +- `ContactCreationActivity.kt` — `@AndroidEntryPoint`, `enableEdgeToEdge()`, `setContent`, `android:launchMode="singleTop"` in manifest +- `ContactCreationEditorScreen.kt` — `Scaffold` + `LargeTopAppBar` + save action + name/phone/email sections +- `ContactCreationViewModel.kt` — full wiring: SavedStateHandle, actions, effects, account loading +- `ContactCreationUiState.kt` — `@Parcelize` data class (name + phone + email fields) +- `NameState.kt` — `@Parcelize` sub-state for name fields +- `ContactCreationAction.kt` / `ContactCreationEffect.kt` — sealed interfaces +- `TestTags.kt` — constants +- `ContactCreationProvidesModule.kt` — Hilt `@Provides` for `AccountTypeManager` +- `RawContactDeltaMapper.kt` — maps UiState → RawContactDeltaList (name, phone, email) +- `ContactFieldsDelegate.kt` — field CRUD with `PersistentList` internally +- `FieldType.kt` — `PhoneType`, `EmailType` sealed classes +- `NameSection.kt`, `PhoneSection.kt`, `EmailSection.kt` — composables +- `AccountChip.kt` — account selection chip + bottom sheet +- Intent extras sanitization in Activity `onCreate()` +- Save callback via `onNewIntent()` +- `AndroidManifest.xml` — register new activity with `ACTION_INSERT`, remove from `ContactEditorActivity` +- `app/build.gradle.kts` + `libs.versions.toml` — add Coil, hilt-navigation-compose, kotlinx-collections-immutable + +**Files:** +| File | Action | +|------|--------| +| `app/src/test/java/com/android/contacts/ui/contactcreation/RawContactDeltaMapperTest.kt` | Create FIRST (red) | +| `app/src/test/java/com/android/contacts/ui/contactcreation/ContactFieldsDelegateTest.kt` | Create FIRST (red) | +| `app/src/test/java/com/android/contacts/ui/contactcreation/ContactCreationViewModelTest.kt` | Create FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/NameSectionTest.kt` | Create FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/PhoneSectionTest.kt` | Create FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/EmailSectionTest.kt` | Create FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/ContactCreationEditorScreenTest.kt` | Create FIRST (red) | +| `src/.../ui/contactcreation/ContactCreationActivity.kt` | Create | +| `src/.../ui/contactcreation/ContactCreationEditorScreen.kt` | Create | +| `src/.../ui/contactcreation/ContactCreationViewModel.kt` | Create | +| `src/.../ui/contactcreation/model/ContactCreationAction.kt` | Create | +| `src/.../ui/contactcreation/model/ContactCreationEffect.kt` | Create | +| `src/.../ui/contactcreation/model/ContactCreationUiState.kt` | Create | +| `src/.../ui/contactcreation/model/NameState.kt` | Create | +| `src/.../ui/contactcreation/TestTags.kt` | Create | +| `src/.../ui/contactcreation/di/ContactCreationProvidesModule.kt` | Create | +| `src/.../ui/contactcreation/delegate/ContactFieldsDelegate.kt` | Create | +| `src/.../ui/contactcreation/component/NameSection.kt` | Create | +| `src/.../ui/contactcreation/component/PhoneSection.kt` | Create | +| `src/.../ui/contactcreation/component/EmailSection.kt` | Create | +| `src/.../ui/contactcreation/component/AccountChip.kt` | Create | +| `src/.../ui/contactcreation/component/FieldType.kt` | Create | +| `src/.../ui/contactcreation/mapper/RawContactDeltaMapper.kt` | Create | +| `AndroidManifest.xml` | Modify | +| `app/build.gradle.kts` | Add deps | +| `gradle/libs.versions.toml` | Add entries | + +**Success criteria:** `./gradlew build` passes. Create contact with name + phone + email → appears in contacts list. All Phase 1 tests green. Process death restores form state. + +#### Phase 2: Extended Fields — Full Parity + +**SDD order:** +1. Expand `RawContactDeltaMapperTest.kt` — tests for all remaining 10 field types (address, org, note, website, event, relation, IM, nickname, SIP, group). Red. +2. Expand `ContactFieldsDelegateTest.kt` — tests for add/remove/update address, events, etc. Add `accountWithoutSip_hidesSipField()`. Red. +3. Write section tests: `AddressSectionTest.kt`, `MoreFieldsSectionTest.kt` (include `customType_opensLabelDialog()`), `GroupSectionTest.kt`. Red. +4. Implement: FieldType expansion → Delegate expansion → Mapper expansion → Sections → Screen wiring. Green. + +**Deliverables:** +- All remaining field types: organization, address, notes, website, events, relations, IM, nickname, SIP, groups +- "More fields" expand/collapse with `AnimatedVisibility` +- Per-field-type composable files +- Group membership picker (account-scoped) +- Custom label dialog for TYPE_CUSTOM + +**Field types and their files:** +| MIME Type | File | Repeatable | +|-----------|------|-----------| +| `StructuredPostal` | `AddressSection.kt` | Yes | +| `Organization` | `OrganizationSection.kt` | No | +| `Event` | `MoreFieldsSection.kt` | Yes | +| `Relation` | `MoreFieldsSection.kt` | Yes | +| `Im` | `MoreFieldsSection.kt` | Yes | +| `Website` | `MoreFieldsSection.kt` | Yes | +| `Note` | `MoreFieldsSection.kt` | No | +| `Nickname` | `MoreFieldsSection.kt` | No | +| `SipAddress` | `MoreFieldsSection.kt` | No | +| `GroupMembership` | `GroupSection.kt` | N/A | + +> **Research insight (SpecFlow):** Account-specific field filtering — some accounts don't support all field types (e.g., SIP, IM). The "more fields" section should hide unsupported types based on the selected account's `DataKind` list via `AccountType.getKindForMimetype()`. + +> **Research insight (SpecFlow):** Groups are account-scoped. Changing the account must clear/refresh the group list. Default group ("My Contacts") may auto-assign on some accounts. + +**Files:** +| File | Action | +|------|--------| +| `app/src/test/java/com/android/contacts/ui/contactcreation/RawContactDeltaMapperTest.kt` | Expand FIRST (red) | +| `app/src/test/java/com/android/contacts/ui/contactcreation/ContactFieldsDelegateTest.kt` | Expand FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/AddressSectionTest.kt` | Create FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/MoreFieldsSectionTest.kt` | Create FIRST (red) | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/GroupSectionTest.kt` | Create FIRST (red) | +| `src/.../ui/contactcreation/component/AddressSection.kt` | Create | +| `src/.../ui/contactcreation/component/OrganizationSection.kt` | Create | +| `src/.../ui/contactcreation/component/MoreFieldsSection.kt` | Create | +| `src/.../ui/contactcreation/component/GroupSection.kt` | Create | +| `src/.../ui/contactcreation/model/ContactCreationUiState.kt` | Expand | +| `src/.../ui/contactcreation/model/ContactCreationAction.kt` | Expand | +| `src/.../ui/contactcreation/component/FieldType.kt` | Expand | +| `src/.../ui/contactcreation/delegate/ContactFieldsDelegate.kt` | Expand | +| `src/.../ui/contactcreation/mapper/RawContactDeltaMapper.kt` | Expand | + +**Success criteria:** All Phase 2 tests green. All field types render, accept input, save correctly. "More fields" expands/collapses. Groups selectable. + +#### Phase 3: Photo Support + +**SDD order:** +1. Expand `RawContactDeltaMapperTest.kt` — test photo URI in updatedPhotos bundle. Red. +2. Expand `ContactCreationViewModelTest.kt` — test SetPhoto/RemovePhoto actions, cleanup on clear. Red. +3. Write `PhotoSectionTest.kt` — test avatar renders, menu opens, actions dispatched. Red. +4. Implement: Mapper photo bundle → ViewModel photo state → PhotoSection → cleanup. Green. + +**Deliverables:** +- Photo avatar composable — tappable circle with camera/gallery/remove dropdown +- `ActivityResultContracts.PickVisualMedia` for gallery (no permissions needed — minSdk 36) +- `ACTION_IMAGE_CAPTURE` implicit intent for camera (no CAMERA permission needed from caller) +- Photo URI passed to save service via `EXTRA_UPDATED_PHOTOS` bundle +- Coil `AsyncImage` for off-thread display with downsampling to avatar size +- Temp file cleanup on discard/cancel/activity finish + +> **Research insight (Security):** Create temp photos in `getCacheDir()/contact_photos/` subdirectory. Delete on discard/cancel in ViewModel `onCleared()`. Scope `file_paths.xml` to `contact_photos/` path only. + +> **Research insight (Security):** Use `ACTION_IMAGE_CAPTURE` implicit intent — does NOT require CAMERA permission from the caller. The system camera app handles it. Pass FileProvider URI via `EXTRA_OUTPUT`. + +> **Research insight (Performance):** Never hold `Bitmap` in state. `AsyncImage(model = photoUri)` with Coil handles off-thread decode, downsampling to display size (96dp = ~288px on xxxhdpi), and LRU caching. + +**Files:** +| File | Action | +|------|--------| +| `src/.../ui/contactcreation/component/PhotoSection.kt` | Create | +| `src/.../ui/contactcreation/ContactCreationEditorScreen.kt` | Wire photo | +| `src/.../ui/contactcreation/ContactCreationViewModel.kt` | Photo state + cleanup | +| `app/src/androidTest/java/com/android/contacts/ui/contactcreation/PhotoSectionTest.kt` | Create FIRST (red) | +| `res/xml/file_paths.xml` | Scope to `contact_photos/` subdirectory | + +**Success criteria:** All Phase 3 tests green. Pick photo from gallery, take with camera, remove. Photo saves with contact. Temp files cleaned on discard. + +#### Phase 4: M3 Expressive + Edge Cases + Polish (merge of original 6-7) + +**SDD order:** +1. Expand `ContactCreationViewModelTest.kt` — test back-with-changes → discard effect, zero-account → local-only, intent extras sanitization. Red. +2. Expand `ContactCreationEditorScreenTest.kt` — test discard dialog renders, more-fields toggle, animations respect reduce-motion. Red. +3. Implement: Theme + animations + dialogs + predictive back + edge cases. Green. + +**Deliverables:** +- `MotionScheme.expressive()` on `AppTheme` (physics-based spring animations) +- Named spring constants: `GentleBounce = spring(DampingRatioLowBouncy, StiffnessMediumLow)`, `SmoothExit = spring(DampingRatioNoBouncy, StiffnessMedium)` +- `animateItem(fadeInSpec = GentleBounce, fadeOutSpec = SmoothExit)` on all LazyColumn items +- `LocalReduceMotion.current` check: `val animSpec = if (reduceMotion) snap() else spring(...)` +- All composables use `MaterialTheme.colorScheme.*` and `MaterialTheme.typography.*` roles +- Icon mapping per field type (reference m3-expressive skill) +- Shape morphing on photo avatar tap +- Animated save button +- Predictive back gesture via `PredictiveBackHandler` (Android 14+) +- Back/cancel with unsaved changes → confirmation dialog +- Keyboard management (focus first field, dismiss on save) +- Zero-account / local-only contact support (critical for GrapheneOS) +- Error handling — generic snackbar messages (never leak PII) + +> **Research insight (Best practices):** No `ExpressiveTopAppBar` exists. Use `LargeTopAppBar` + `MotionScheme.expressive()` on the theme. `MaterialExpressiveTheme` is alpha-only; stick with `MaterialTheme` + motionScheme parameter. + +> **Research insight (SpecFlow):** GrapheneOS users frequently have no Google account. MUST support device-local contacts (`setAccountToLocal()`). Zero-account = device-only, not an error state. + +> **Research insight (Security):** Error messages must be generic: "Could not save contact. Please try again." Never include field values or account names in user-visible messages. + +> **Research insight (Performance):** Use `animateItem()` (LazyColumn built-in) not per-item `AnimatedVisibility`. Profile on Pixel 3a-class device. Skip spring animations when `isReduceMotionEnabled`. + +**Files:** +| File | Action | +|------|--------| +| `src/.../ui/core/Theme.kt` | Add MotionScheme.expressive() | +| `src/.../ui/contactcreation/ContactCreationEditorScreen.kt` | Dialogs, back handling, animations | +| `src/.../ui/contactcreation/ContactCreationViewModel.kt` | Edge case logic | +| `src/.../ui/contactcreation/component/*.kt` | Add animateItem(), spring motion | + +#### Phase 5: Test Hardening & Coverage Audit + +**Note:** This phase is coverage hardening, not SDD — tests here catch gaps, not drive new implementation. Tests are written BEFORE implementation in each prior phase (SDD). This phase is for hardening: filling coverage gaps, adding edge case tests, and verifying the full test suite runs end-to-end. + +**SDD order:** +1. Run `./gradlew test` + `./gradlew connectedAndroidTest` — identify any gaps. +2. Add missing edge case tests (e.g., max field count, concurrent save, rapid add/remove). +3. Add integration tests for intent extras → pre-fill → save flow. +4. Verify all ~75 tests pass. + +**UI Tests (androidTest) — state-down/events-up pattern:** +```kotlin +class ContactCreationEditorScreenTest { + @get:Rule val composeTestRule = createAndroidComposeRule() + + private val capturedActions = mutableListOf() + + @Test fun initialState_showsNameAndPhoneFields() { + setContent() + composeTestRule.onNodeWithTag(TestTags.NAME_FIRST).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.phoneField(0)).assertIsDisplayed() + } + + @Test fun tapSave_dispatchesSaveAction() { + setContent() + composeTestRule.onNodeWithTag(TestTags.SAVE_BUTTON).performClick() + assertEquals(ContactCreationAction.Save, capturedActions.last()) + } + + private fun setContent(state: ContactCreationUiState = ContactCreationUiState()) { + composeTestRule.setContent { + AppTheme { + ContactCreationEditorScreen( + uiState = state, + onAction = { capturedActions.add(it) }, + ) + } + } + } +} +``` + +> **Research insight (Simplicity):** No MockK needed for UI tests. Lambda capture `onAction = { capturedActions.add(it) }` replaces `mockk(relaxed = true)` + `verify()`. Simpler, faster, no mock framework dependency in androidTest. + +**ViewModel Tests (test):** +```kotlin +@RunWith(RobolectricTestRunner::class) +class ContactCreationViewModelTest { + @get:Rule val mainDispatcherRule = MainDispatcherRule() + + @Test fun saveAction_emitsSaveEffect() = runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel(initialState = stateWithData()) + vm.effects.test { + vm.onAction(ContactCreationAction.Save) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test fun addPhoneAction_addsEmptyPhoneRow() = runTest(mainDispatcherRule.testDispatcher) { + val vm = createViewModel() + vm.onAction(ContactCreationAction.AddPhone) + assertEquals(2, vm.uiState.value.phoneNumbers.size) + } +} +``` + +**Mapper Tests (test) — highest priority, most risk:** +```kotlin +class RawContactDeltaMapperTest { + private val mapper = RawContactDeltaMapper() + + @Test fun mapsNameFields_toStructuredNameDelta() { + val state = ContactCreationUiState(firstName = "John", lastName = "Doe") + val result = mapper.map(state, account = null) + val nameDelta = result.state[0].getMimeEntries(StructuredName.CONTENT_ITEM_TYPE) + assertEquals("John", nameDelta[0].getAsString(StructuredName.GIVEN_NAME)) + assertEquals("Doe", nameDelta[0].getAsString(StructuredName.FAMILY_NAME)) + } + + @Test fun emptyFields_notIncludedInDelta() { + val state = ContactCreationUiState( + phoneNumbers = listOf(PhoneFieldState(number = "", type = PhoneType.MOBILE)) + ) + val result = mapper.map(state, account = null) + val phoneDelta = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE) + assertTrue(phoneDelta.isNullOrEmpty()) + } + + @Test fun customTypeLabel_setsBothTypeAndLabel() { + val state = ContactCreationUiState( + phoneNumbers = listOf( + PhoneFieldState(number = "555", type = PhoneType.Custom("Work cell")) + ) + ) + val result = mapper.map(state, account = null) + val phone = result.state[0].getMimeEntries(Phone.CONTENT_ITEM_TYPE)!![0] + assertEquals(Phone.TYPE_CUSTOM, phone.getAsInteger(Phone.TYPE)) + assertEquals("Work cell", phone.getAsString(Phone.LABEL)) + } + + @Test fun photoUri_addedToUpdatedPhotosBundle() { + val photoUri = Uri.parse("content://test/photo.jpg") + val state = ContactCreationUiState(photoUri = photoUri) + val result = mapper.map(state, account = null) + val rawContactId = result.state[0].values.id.toString() + assertEquals(photoUri, result.updatedPhotos.getParcelable(rawContactId, Uri::class.java)) + } + + // Test ALL 13 field types... +} +``` + +**Test coverage targets:** +| Layer | Files | Tests | +|-------|-------|-------| +| UI - Editor screen | `ContactCreationEditorScreenTest.kt` | ~20 tests | +| UI - Sections | `PhoneSectionTest.kt`, `EmailSectionTest.kt`, etc. | ~15 tests | +| ViewModel | `ContactCreationViewModelTest.kt` | ~15 tests | +| Delegate | `ContactFieldsDelegateTest.kt` | ~10 tests | +| Mapper | `RawContactDeltaMapperTest.kt` | ~15 tests (highest priority) | +| **Total** | **~7 test files** | **~75 tests** | + +**TestTags — flat constants with helper functions for indexed fields:** +```kotlin +internal object TestTags { + const val SCREEN = "contact_creation_screen" + const val SAVE_BUTTON = "contact_creation_save" + const val BACK_BUTTON = "contact_creation_back" + const val ACCOUNT_CHIP = "contact_creation_account_chip" + const val PHOTO_AVATAR = "contact_creation_photo" + const val MORE_FIELDS = "contact_creation_more_fields" + + // Name + const val NAME_PREFIX = "contact_creation_name_prefix" + const val NAME_FIRST = "contact_creation_name_first" + const val NAME_MIDDLE = "contact_creation_name_middle" + const val NAME_LAST = "contact_creation_name_last" + const val NAME_SUFFIX = "contact_creation_name_suffix" + + // Indexed field helpers + fun phoneField(index: Int) = "contact_creation_phone_$index" + fun phoneType(index: Int) = "contact_creation_phone_type_$index" + fun phoneDelete(index: Int) = "contact_creation_phone_delete_$index" + const val PHONE_ADD = "contact_creation_phone_add" + + fun emailField(index: Int) = "contact_creation_email_$index" + fun emailType(index: Int) = "contact_creation_email_type_$index" + fun emailDelete(index: Int) = "contact_creation_email_delete_$index" + const val EMAIL_ADD = "contact_creation_email_add" + + // Same pattern for address, event, im, relation, website... + + const val ORG_COMPANY = "contact_creation_org_company" + const val ORG_TITLE = "contact_creation_org_title" + const val NICKNAME = "contact_creation_nickname" + const val NOTES = "contact_creation_notes" + const val SIP = "contact_creation_sip" + const val GROUPS = "contact_creation_groups" + + // Dialogs + const val DISCARD_DIALOG = "contact_creation_discard_dialog" + const val DISCARD_YES = "contact_creation_discard_yes" + const val DISCARD_NO = "contact_creation_discard_no" + const val CUSTOM_LABEL_DIALOG = "contact_creation_custom_label_dialog" + const val CUSTOM_LABEL_INPUT = "contact_creation_custom_label_input" + const val ACCOUNT_SHEET = "contact_creation_account_sheet" + + // Photo + const val PHOTO_MENU = "contact_creation_photo_menu" + const val PHOTO_GALLERY = "contact_creation_photo_gallery" + const val PHOTO_CAMERA = "contact_creation_photo_camera" + const val PHOTO_REMOVE = "contact_creation_photo_remove" +} +``` + +#### Phase 6: CLAUDE.md & Skills Setup + +**Project CLAUDE.md** at `.claude/CLAUDE.md`: +- Build commands, architecture conventions, test patterns +- Reference to this plan and brainstorm +- TestTag naming conventions + +**Skills** (8 skills covering every aspect of the implementation): + +| Skill | File | Purpose | +|-------|------|---------| +| `sdd-workflow` | `.claude/skills/sdd-workflow.md` | **Start here.** Spec-driven dev cycle: plan → tests (red) → stubs → impl (green) → lint | +| `android-build` | `.claude/skills/android-build.md` | Run build, lint, test commands with error parsing | +| `compose-screen` | `.claude/skills/compose-screen.md` | Generate Compose screen following state-down/events-up pattern | +| `compose-test` | `.claude/skills/compose-test.md` | Generate UI/ViewModel/Mapper tests with testTag + lambda capture | +| `m3-expressive` | `.claude/skills/m3-expressive.md` | M3 Expressive components, animations, theme, icon mapping | +| `viewmodel-pattern` | `.claude/skills/viewmodel-pattern.md` | Generate ViewModel + Action/Effect/UiState MVI skeleton | +| `hilt-module` | `.claude/skills/hilt-module.md` | Generate Hilt @Provides/@Binds modules | +| `delta-mapper` | `.claude/skills/delta-mapper.md` | RawContactDelta construction, column reference, save service contract | + +--- + +## Concrete RawContactDeltaMapper Implementation + +> **Research insight (RawContactDelta bridging):** Full implementation derived from source code analysis of `ValuesDelta.fromAfter()`, `RawContactDelta.addEntry()`, `ContactSaveService.createSaveContactIntent()`, and `RawContactModifier.trimEmpty()`. + +```kotlin +data class DeltaMapperResult( + val state: RawContactDeltaList, + val updatedPhotos: Bundle, +) + +class RawContactDeltaMapper @Inject constructor() { + fun map(uiState: ContactCreationUiState, account: AccountWithDataSet?): DeltaMapperResult { + val rawContact = RawContact().apply { + if (account != null) setAccount(account) else setAccountToLocal() + } + val delta = RawContactDelta(ValuesDelta.fromAfter(rawContact.values)) + val rawContactId = delta.values.id // negative temp ID from sNextInsertId-- + + // Name + if (uiState.hasNameData()) { + delta.addEntry(ValuesDelta.fromAfter(contentValues(StructuredName.CONTENT_ITEM_TYPE) { + put(StructuredName.GIVEN_NAME, uiState.firstName) + put(StructuredName.FAMILY_NAME, uiState.lastName) + put(StructuredName.PREFIX, uiState.namePrefix) + put(StructuredName.MIDDLE_NAME, uiState.middleName) + put(StructuredName.SUFFIX, uiState.nameSuffix) + })) + } + // Phones — skip blank entries (trimEmpty handles it, but save hasPendingChanges check) + for (phone in uiState.phoneNumbers) { + if (phone.number.isBlank()) continue + delta.addEntry(ValuesDelta.fromAfter(contentValues(Phone.CONTENT_ITEM_TYPE) { + put(Phone.NUMBER, phone.number) + put(Phone.TYPE, phone.type.rawValue) + if (phone.type is PhoneType.Custom) put(Phone.LABEL, phone.type.label) + })) + } + // ... same pattern for all 13 field types (emails, addresses, org, notes, etc.) + + val state = RawContactDeltaList().apply { add(delta) } + val updatedPhotos = Bundle() + uiState.photoUri?.let { updatedPhotos.putParcelable(rawContactId.toString(), it) } + + return DeltaMapperResult(state, updatedPhotos) + } + + private inline fun contentValues(mimeType: String, block: ContentValues.() -> Unit) = + ContentValues().apply { put(Data.MIMETYPE, mimeType); block() } +} +``` + +Key edge cases from source analysis: +- `ValuesDelta.fromAfter()` assigns negative temp IDs via `sNextInsertId--` +- `ContactSaveService.saveContact()` calls `RawContactModifier.trimEmpty()` before building diff — empty entries are auto-cleaned +- Photos are separate from delta list — passed via `EXTRA_UPDATED_PHOTOS` bundle keyed by String of rawContactId +- For `TYPE_CUSTOM`, must set BOTH the type column AND the label column +- **IMPORTANT: IM uses PROTOCOL + CUSTOM_PROTOCOL (not TYPE + LABEL like other field types)** + +--- + +## System-Wide Impact + +### Interaction Graph + +``` +User taps "+" (FAB/menu in PeopleActivity) + → Intent(ACTION_INSERT, Contacts.CONTENT_URI) + → ContactCreationActivity.onCreate() # NEW + → Sanitize intent extras (cap lengths, validate accounts) + → setContent { AppTheme { ContactCreationEditorScreen(...) } } + → ContactCreationViewModel.init() + → Load writable accounts via AccountTypeManager + → If zero accounts → show local-only prompt + → If single → auto-select + → If multiple → show account chip + → User fills form, dispatches Actions + → Action.Save → ViewModel + → RawContactDeltaMapper.map(uiState, account) # on Dispatchers.Default + → Effect.Save(deltaList, photos) + → LaunchedEffect → ContactSaveService.createSaveContactIntent( + context, state, "saveMode", SaveMode.CLOSE, + false, ContactCreationActivity::class.java, + SAVE_COMPLETED_ACTION, updatedPhotos, null, null + ) + → context.startService(intent) + → ContactSaveService.saveContact() # EXISTING (Java) + → RawContactModifier.trimEmpty() # auto-cleans empty fields + → ContentResolver.applyBatch() # SYSTEM + → Callback Intent(SAVE_COMPLETED_ACTION) + → ContactCreationActivity.onNewIntent() # receive callback + → viewModel.onSaveResult(success, contactUri) + → finish() or show error snackbar +``` + +### Error Propagation + +| Error | Source | Handling | +|-------|--------|----------| +| Save failure | ContactSaveService callback (null URI) | Generic snackbar: "Could not save contact" | +| No writable accounts | AccountTypeManager | UiState → local-only prompt | +| Photo temp file creation fails | IOException in cache dir | Snackbar, photo section disabled | +| Permission revoked mid-save | SecurityException in ContentProvider | Caught in save service, null URI callback | +| Intent extras too large | External app sends oversized strings | Truncated in onCreate() sanitization | + +### State Lifecycle Risks + +- **Partial save**: `applyBatch()` is atomic per batch. No orphan risk. +- **Process death**: `SavedStateHandle` with `@Parcelize` UiState. All field data persisted. Restored transparently by ViewModel. +- **Photo temp file**: Created in `getCacheDir()/contact_photos/`. Deleted in `ViewModel.onCleared()` if not saved. Subdirectory wiped on activity start as safety net. + +> **Research insight (Security):** PII in SavedStateHandle is serialized to disk by ActivityManager. This matches existing behavior (current editor uses Parcelable RawContactDeltaList). Document as explicit privacy tradeoff. GrapheneOS per-profile encryption provides defense-in-depth. + +### Security Considerations + +| Finding | Severity | Mitigation | +|---------|----------|------------| +| Intent extras injection via `Insert.DATA` | HIGH | Drop `Insert.DATA` support. Only accept known extras (`Insert.NAME`, `Insert.PHONE`, `Insert.EMAIL`, etc.) with max-length caps | +| PII in SavedStateHandle | MEDIUM | Matches existing behavior. Document tradeoff. Clear in `onDestroy(isFinishing=true)` | +| Photo temp files on discard | MEDIUM | Delete in `ViewModel.onCleared()`. Wipe subdirectory on activity start | +| Exported activity without validation | MEDIUM | Sanitize all extras in `onCreate()`. Validate `EXTRA_ACCOUNT` against writable accounts | +| Error messages leak PII | LOW | Generic error strings only. Debug-level logging for details | + +--- + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Create contact with all field types (name, phone, email, address, org, notes, website, events, relations, IM, nickname, SIP, groups) +- [ ] Add/remove multiple instances of repeatable fields +- [ ] Change field type labels (Home/Work/Mobile/Custom) +- [ ] Custom label dialog for TYPE_CUSTOM +- [ ] Select account when multiple writable accounts exist +- [ ] Device-local contact creation when zero accounts (critical for GrapheneOS) +- [ ] Add photo from gallery (PickVisualMedia) or camera (ACTION_IMAGE_CAPTURE) +- [ ] Remove photo +- [ ] Expand/collapse "more fields" section +- [ ] Account-specific field filtering (hide unsupported types) +- [ ] Back with unsaved changes shows confirmation dialog (including predictive back gesture) +- [ ] Handle `ACTION_INSERT` intent with extras (pre-fill fields, sanitized) +- [ ] Save creates contact visible in contacts list +- [ ] Empty form save does nothing + +### Non-Functional Requirements + +- [ ] M3 with `MotionScheme.expressive()` — spring animations, `animateItem()` on field add/remove +- [ ] Dynamic color theme (Material You) via existing `AppTheme` +- [ ] Edge-to-edge display +- [ ] Keyboard focus management +- [ ] All interactive elements have `testTag()` +- [ ] No hardcoded strings — all from `R.string.*` +- [ ] Process death restores form state via `SavedStateHandle` +- [ ] Photo temp files cleaned on discard/cancel +- [ ] Intent extras sanitized with max-length caps +- [ ] Respect `isReduceMotionEnabled` accessibility setting + +### Testing Requirements + +- [ ] ~75 tests across ~7 test files +- [ ] UI tests use `testTag()` exclusively (zero `onNodeWithText`) +- [ ] UI tests use lambda capture (no MockK for UI layer) +- [ ] ViewModel tests use fake delegate + Turbine +- [ ] Mapper tests cover ALL 13 field types + edge cases (highest priority) +- [ ] All tests pass: `./gradlew test` and `./gradlew connectedAndroidTest` + +### Quality Gates + +- [ ] `./gradlew build` passes (includes ktlint + detekt) +- [ ] No `any` types or suppressed warnings +- [ ] All composables `internal` visibility +- [ ] All state classes `@Parcelize` +- [ ] Zero View/Fragment dependencies in new code +- [ ] Coil for all image loading (no main-thread bitmap decode) + +--- + +## Dependencies & Prerequisites + +| Dependency | Status | +|------------|--------| +| Gradle + version catalog | Done (main branch) | +| Compose BOM 2026.03.01 | Done (app/build.gradle.kts) | +| Hilt setup | Done (`@HiltAndroidApp`, dispatchers module) | +| ktlint + detekt | Done (build.gradle.kts) | +| M3 theme (`AppTheme`) | Done (ui/core/Theme.kt) — add MotionScheme | +| `ContactSaveService` | Existing Java — no changes needed | +| `RawContactDelta` / `ValuesDelta` | Existing Java — consumed from Kotlin | +| **Coil Compose** | **TODO** — add to version catalog + build.gradle.kts | +| **hilt-navigation-compose** | **TODO** — needed for `hiltViewModel()` | +| **kotlinx-collections-immutable** | **TODO** — needed for `PersistentList` | + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `RawContactDeltaMapper` incorrectly builds delta | Medium | High | 15 dedicated mapper tests; concrete implementation from source analysis; compare output to legacy editor | +| M3 Expressive APIs unstable | Medium | Low | Use `MotionScheme.expressive()` on stable `MaterialTheme` only. No alpha-only components | +| Process death loses form state | Low | Medium | `SavedStateHandle` + `@Parcelize` from Phase 1 | +| `ContactSaveService` callback not received | Low | Medium | `onNewIntent()` + matching `callbackAction` string; test with real save | +| Photo temp file leak | Low | Low | Cleanup in `onCleared()` + subdirectory wipe on start | +| Intent extras injection | Medium | Medium | Strict allowlist + length caps in `onCreate()` | +| Large form recomposition overhead | Low | Medium | State slices per section + `PersistentList` + stable keys | + +--- + +## Files Eliminated (vs Original Plan) + +| Eliminated File | Reason | +|----------------|--------| +| `ContactCreationScreen.kt` (routing) | Single screen — no routing needed | +| `ContactCreationNavRoute.kt` | No navigation routes | +| `ContactCreationEffectHandler.kt` | Effects handled inline via `LaunchedEffect` | +| `ContactCreationUiStateMapper.kt` | ViewModel produces UiState directly | +| `ContactCreationModule.kt` (@Binds) | No interfaces to bind — use `@Provides` module instead | +| `PhotoDelegate.kt` | Trivial state folded into ViewModel | +| `AccountDelegate.kt` | Trivial state folded into ViewModel | + +**Net: 7 files eliminated, ~400-500 LOC saved.** + +--- + +## Sources & References + +### Origin + +- **Brainstorm:** [docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md](docs/brainstorms/2026-04-14-contact-creation-compose-rewrite-brainstorm.md) +- Decisions carried forward: reuse ContactSaveService, testTag-only testing, M3 Expressive, Kotlin rewrite for field types +- Decision revised: simplified architecture (dropped ScreenModel, NavRoute, extra delegates) + +### Internal References + +- `src/com/android/contacts/editor/ContactEditorFragment.java` — current implementation (1892 lines) +- `src/com/android/contacts/ContactSaveService.java:463` — `createSaveContactIntent()` signature +- `src/com/android/contacts/model/RawContactDelta.java` — `addEntry()`, `buildDiff()` +- `src/com/android/contacts/model/ValuesDelta.java:72` — `fromAfter()`, temp ID assignment at line 78 +- `src/com/android/contacts/model/RawContact.java:298` — `setAccount()`, `setAccountToLocal()` +- `src/com/android/contacts/model/RawContactModifier.java:413` — `trimEmpty()` behavior +- `src/com/android/contacts/editor/EditorUiUtils.java` — field type icons (reference for Compose Material Icons mapping) +- `src/com/android/contacts/ui/core/Theme.kt` — existing M3 Compose theme +- `src/com/android/contacts/di/core/CoreProvidesModule.kt` — existing Hilt dispatchers +- `app/build.gradle.kts` — Compose + Hilt + test dependencies +- `gradle/libs.versions.toml` — version catalog + +### External References + +- [GrapheneOS Messaging PR #101](https://github.com/GrapheneOS/Messaging/pull/101) — reference patterns (adapted, not copied) +- [Material 3 Expressive](https://developer.android.com/develop/ui/compose/designsystems/material3-expressive) — MotionScheme docs +- [Compose Testing](https://developer.android.com/develop/ui/compose/testing) — testTag patterns +- [Android Photo Picker](https://developer.android.com/training/data-storage/shared/photo-picker) — PickVisualMedia (guaranteed on minSdk 36) +- [Hilt 2.59.2 Release](https://github.com/google/dagger/releases/tag/dagger-2.59.2) — AGP 9 compatibility +- [Turbine 1.2.1](https://github.com/cashapp/turbine/releases/tag/1.2.1) — Flow testing +- [kotlinx-collections-immutable](https://github.com/Kotlin/kotlinx.collections.immutable) — PersistentList diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..3c5e11311 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..ef2b661e9 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,90 @@ +[versions] +agp = "9.1.0" +detekt = "2.0.0-alpha.2" +hilt = "2.59.2" +kotlin = "2.3.20" +ksp = "2.3.6" +ktlint = "1.8.0" +ktlint-gradle = "14.2.0" + +activity-compose = "1.13.0" +coil = "3.2.0" +collections-immutable = "0.3.8" +hilt-navigation-compose = "1.3.0" +appcompat = "1.7.1" +compose-bom = "2026.03.01" +coroutines = "1.10.2" +guava = "33.5.0-android" +material = "1.13.0" +palette = "1.0.0" +swiperefreshlayout = "1.2.0" + +junit4 = "4.13.2" +mockk = "1.14.9" +robolectric = "4.16.1" +turbine = "1.2.1" + +androidx-test-espresso = "3.7.0" +androidx-test-ext-junit = "1.3.0" +androidx-test-runner = "1.7.0" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } + +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } + +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" } + +androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" } +androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } + +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } + +guava = { module = "com.google.guava:guava", version.ref = "guava" } + +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } + +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "collections-immutable" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +material = { module = "com.google.android.material:material", version.ref = "material" } + +junit4 = { module = "junit:junit", version.ref = "junit4" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } + +hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } + +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } + +detekt = { id = "dev.detekt", version.ref = "detekt" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } + +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 000000000..77be903dd --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,7658 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..a4b76b953 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..8e61ef125 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..f5feea6d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/platform_external_libphonenumber b/lib/platform_external_libphonenumber new file mode 160000 index 000000000..4814ded38 --- /dev/null +++ b/lib/platform_external_libphonenumber @@ -0,0 +1 @@ +Subproject commit 4814ded38ba34c69da1fcb949b9d47640d854c50 diff --git a/lib/platform_frameworks_ex b/lib/platform_frameworks_ex new file mode 160000 index 000000000..dbacb84fe --- /dev/null +++ b/lib/platform_frameworks_ex @@ -0,0 +1 @@ +Subproject commit dbacb84fe6e498775194b6e2cdc3e09d8ce25ab8 diff --git a/lib/platform_frameworks_opt_vcard b/lib/platform_frameworks_opt_vcard new file mode 160000 index 000000000..274080f62 --- /dev/null +++ b/lib/platform_frameworks_opt_vcard @@ -0,0 +1 @@ +Subproject commit 274080f626ebfee216f926c5a8e2a932cb2b01dd diff --git a/lib/platform_packages_apps_PhoneCommon b/lib/platform_packages_apps_PhoneCommon new file mode 160000 index 000000000..0f8bb7daa --- /dev/null +++ b/lib/platform_packages_apps_PhoneCommon @@ -0,0 +1 @@ +Subproject commit 0f8bb7daabecebdc56c2114f112ff229b17d19f1 diff --git a/res/values/styles.xml b/res/values/styles.xml index db5e27f48..385deb2d1 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -16,6 +16,8 @@ +