Skip to content

Android parity 1/3: forms come out right and complete - #33

Merged
roznet merged 7 commits into
mainfrom
claude/eager-cori-hiygcs
Sep 26, 2026
Merged

roznet merged 7 commits into
mainfrom
claude/eager-cori-hiygcs

Conversation

@roznet

@roznet roznet commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Closes #29. PR 1 of 3 in designs/future/android-parity.md §4. One commit per section; the tracker's items are ticked and the decisions logged in §8 in the same commits.

What changes

1a Correctness fixes

  • The first crew member is sent as "Pilot" (was "PIC", printed on the gendec and LSGS forms).
  • A local flight shows both its arrival and its departure forms. Only web forms are filtered by direction, as on iOS.
  • The flight editor edits a draft held by the ViewModel: Save stores flight, crew and passengers together, Back with changes asks Save / Discard, and + opens flight/new, which no longer leaves a "???? → ????" row when abandoned.
  • The OAuth state nonce survives process death (SharedPreferences, 10-minute TTL). A 401 to an authenticated request clears the token and returns to sign-in, and Settings offers sign-in after "Enter data without signing in".
  • Web forms: Back walks the page history first; cookies, storage and cache are cleared on exit.
  • Generated forms and data exports in cacheDir/forms are cleared on cold start and, on returning to the app, once older than 15 minutes.
  • Move my data keeps trip extra fields. Aircraft chips scroll, people chips wrap, and the aircraft editor scrolls.

1b Delete account, delete with Undo

  • Settings → Delete account (DELETE /auth/account) after a confirm. Local data is kept.
  • Swipe-to-delete and an overflow-menu Delete for people, aircraft and flights, with an Undo snackbar. Deletes stay tombstones; Undo clears the tombstone and bumps updatedAt.

1c Flight fields

  • Nature, Reason for Visit, Responsible Person (the form contact; fills the telephone/email extras).
  • Connecting flight and return flight (has_return_flight), found by FlightLegs in :core-logic.
  • Per-form extra fields (choice / person / text), sent with the request. An untouched choice sends the option it shows (see below).

1d Leg actions

  • Create Return Flight / Create Next Leg / Duplicate Flight. The current flight is stored first; the new leg opens as an unsaved draft.
  • Moving the departure moves an arrival on the same UTC day with it.
  • Past flights are collapsed by default, and every row shows the registration.

1e Email export

  • Email next to Generate: /email-text runs alongside generation, then a mail app opens (ACTION_SEND with a mailto: selector) with to/cc from email/send_to. With no mail app it falls back to the share sheet.
  • Settings → Languages you speak, stored under the iOS key and format.
  • The primary form comes first, then web forms, then "Other forms" (collapsed).

Plus a follow-up fix so each sign-in redirect is handled exactly once however the activity is recreated.

Deliberate departures from iOS (logged in §8)

  • New legs are unsaved drafts rather than inserted immediately.
  • An untouched choice extra field sends its displayed first option. iOS sends nothing, which the server rejects as missing when the field is required. That is an iOS bug worth fixing separately.
  • Upcoming flights are sorted soonest first.

Verification: not complete, hence draft

The session that wrote this could not reach dl.google.com (Google Maven + Android SDK), so ./gradlew :app:… was never run and nothing was driven on the emulator. What was checked:

  • :core-logic:test: 116 tests pass, 36 new (FormSides, FlightLegs, EmailText, TripExtras).
  • app/src/test (FormRequestBuilderTest, ApiTypesTest, 20 tests) passes on the JVM.
  • app/src/main, except MainActivity and scan/, compiles against Compose Multiplatform 1.6 desktop plus stubs for the Android APIs. SwipeToDismissBox(onDismiss = …) is newer than that; it was confirmed present in material3 1.4 sources.
  • New instrumented tests (trip extras round trip, delete/restore) are written but not run.

Before marking ready:

  • ./gradlew :app:testDebugUnitTest :core-logic:test and :app:connectedDebugAndroidTest
  • Drive on the emulator: new flight + Back, Save/Discard prompt, local flight forms, leg actions, extra fields, Email, swipe + Undo, Delete account, sign-in after skip

🤖 Generated with Claude Code

https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2


Generated by Claude Code

…etting lost

Correctness fixes from the iOS parity survey (designs/future/android-parity.md §4 1a):

- The first crew member is sent as "Pilot", as iOS does; "PIC" was being
  printed on the gendec and LSGS forms.
- A local flight shows both its arrival and its departure forms. Only web
  forms are filtered by direction; document forms show on both sides.
- The flight editor edits a draft: Save stores flight, crew and passengers
  together, Back with changes asks Save / Discard, and + no longer leaves a
  "???? -> ????" row when abandoned.
- The OAuth state nonce survives process death (10-minute TTL). A 401 clears
  the token and returns to sign-in, and Settings offers sign-in after
  skipping it.
- Web forms: Back walks the page history first; cookies, storage and cache
  are cleared on the way out.
- Generated forms and exports are cleared on start and, 15 minutes after a
  share, on returning to the app.
- Move my data keeps trip extra fields; aircraft chips scroll, people chips
  wrap, and the aircraft editor scrolls above the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
- Settings > Delete account calls DELETE /auth/account after a confirm, then
  signs out. Local people, aircraft and flights are kept. Play requires the
  in-app deletion.
- People, aircraft and flights delete with a swipe or from the edit screen's
  overflow menu, and offer Undo in a snackbar. Deletion stays a tombstone;
  Undo clears it and bumps updatedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
- Nature, Reason for Visit and Responsible Person on the flight. The
  responsible person is the form's contact, keeps `contact` in step with
  their phone as iOS does, and fills the telephone / e-mail extras.
- Connecting flight (the next or previous leg through this airport within
  14 days) and return flight (has_return_flight) are sent to the forms that
  ask for them. The matching is FlightLegs in :core-logic, unit-tested.
- Each form shows its own extra fields (choice, person, text) and sends
  them. An untouched choice sends the option it shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
…hts fold away

- Create Return Flight, Create Next Leg and Duplicate Flight, as on iOS:
  the aircraft, people, nature, reason and responsible person carry over.
  The current flight is stored first; the new leg opens as an unsaved draft.
- Moving the departure moves an arrival on the same UTC day with it,
  keeping its time of day. FlightLegs.arrivalFollowing, unit-tested.
- Past flights sit under a collapsed "Past Flights" row, and every row shows
  the aircraft registration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
- Email next to Generate: the form is generated while /email-text writes
  the covering text, then a mail app opens addressed to the form's
  email.to / send_to and cc, with the file attached. With no mail app it
  falls back to the share sheet.
- Settings > Languages you speak. The body is in the airport's language when
  the pilot speaks it, English otherwise; stored as on iOS.
- Each side lists its primary form first, then the airport's web forms, then
  the other forms folded under "Other forms".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
… is recreated

Skipping the redirect whenever there was saved state could drop a callback
that arrives as the creating intent after process death. Remember the one
already handled instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
@gitguardian

gitguardian Bot commented Sep 26, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
37454865 Triggered Generic Password bf2af5b app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/DataTransferTest.kt View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

…a literal

GitGuardian flagged the literal as a generic password. It only ever
encrypted an in-memory test database, so nothing needs rotating; this
stops the new line tripping the scanner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2
@claude

claude Bot commented Sep 26, 2026

Copy link
Copy Markdown

Reviewed the diff against designs/ios-app.md and designs/future/android-parity.md. Most of the PR (draft-based flight editor, leg actions, per-form extra fields, delete/undo, e-mail export, auth-redirect dedup) matches the documented iOS behavior and is backed by the new core-logic tests. One issue found:

FlightsViewModel.setResponsiblePerson stores the wrong value in contact — app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightsViewModel.kt:232-237

/** Also keeps `contact` in step, as iOS `setResponsiblePerson` does for older builds reading it. */
fun setResponsiblePerson(person: PersonEntity?) = edit {
    it.copy(
        flight = it.flight.copy(responsiblePersonId = person?.id, contact = person?.phone),
        responsiblePerson = person,
    )
}

Per designs/ios-app.md (§ Responsible Person & Contact Auto-Fill): "flight.contact is set to their displayName". This sets it to person?.phone instead. FormRequestBuilder.build's flightPayload(flight, responsiblePerson?.displayName) override masks this while the in-memory responsiblePerson is still populated, so today's form generation isn't affected — but the stored flight.contact field itself becomes a phone number. That surfaces once the live override is gone, e.g. if the chosen responsible person is later deleted from People: loadStored() re-resolves responsiblePerson from responsiblePersonId and gets null, flightPayload falls back to the stored flight.contact, and the generated form's contact would show a phone number where a name is expected (per the without a responsible person the stored contact is sent test's own assumption that flight.contact holds a name). Should be contact = person?.displayName.

🤖 Generated with Claude Code

@claude

claude Bot commented Sep 26, 2026

Copy link
Copy Markdown

Code review

Reviewed each commit (1a-1e, the sign-in redirect fix, and the test fix) against the intent logged in designs/future/android-parity.md §4/§8, plus the iOS reference where relevant. Two findings, both plausible and worth a look before merge; everything else checked out against the stated intent.

1. Delete-account failure can silently sign the user out with no visible error (medium-high confidence)

AuthService.kt's deleteAccount() treats a non-2xx response as failure and returns Result.failure(...) without touching the token:

suspend fun deleteAccount(): Result<Unit> = runCatching {
    val response = api.auth.deleteAccount()
    if (!response.isSuccessful) error("The server returned \${response.code()}. Your account was not deleted.")
    tokens.clear()
}

But ApiClient.kt's shared authInterceptor unconditionally clears the token on any 401 response, before this check runs:

if (response.code == 401 && token != null) tokens.clearIfCurrent(token)

If the token happens to be expired/revoked when the pilot taps Delete account, the interceptor clears it first, tokens.signedIn flips to false, and FlyFunApp's top-level if (!signedIn && !skippedSignIn) SignInScreen(...) else NavHost(...) immediately tears down the whole NavHost - including the Settings composable holding deleteAccountError. deleteAccount() still returns Result.failure(...), but .onFailure { deleteAccountError = it.message ... } writes into state that has already left composition. The pilot is bounced to sign-in with no error shown, and no account was actually deleted - a misleading outcome for a destructive action.

Suggest checking tokens.isSignedIn (or a dedicated flag) before letting the interceptor's signedIn flip navigate away from Settings, or having deleteAccount() bypass/ignore the interceptor's side effect and surface the error explicitly.

2. New-leg draft (Return/Next Leg/Duplicate) can be silently lost to process death (medium confidence)

FlightsViewModel.createLeg stores the current flight, then swaps _detail in place to the new draft via show(..., unsaved = true) - it never calls nav.navigate(...) to point the route at a new flightId:

private fun createLeg(shape: ...) = viewModelScope.launch {
    save().join()
    val current = _detail.value ?: return@launch
    ...
    show(current.copy(flight = shape(from, common), isNew = true), unsaved = true)
}

The screen's NavBackStackEntry (and its flightId argument) still points at the old, now-saved flight. If the process is reclaimed while the pilot is mid-edit on the new leg (plausible on Android, unlike iOS's persistence model), Compose Navigation restores that entry with the stale flightId; a fresh FlightsViewModel is created (opened = false), and LaunchedEffect(flightId) { vm.open(flightId) } reloads the old, already-saved flight - silently replacing the in-progress new-leg draft. BackHandler's Save/Discard prompt never gets a chance to fire since there's no back navigation event, just a process restart.

Worth confirming this is an accepted edge case, or navigating to the new draft's own route (e.g. flight/new or a synthetic id) so restoration lands on the right screen.

Everything else

Crew-role rename to "Pilot", local-flight form-side filtering, the persisted OAuth state nonce and its TTL, 401->sign-in routing, the draft/save flow for the flight editor (verified nothing writes to Room before an explicit Save), WebView back/cookie handling, cacheDir/forms cleanup timing (verified it can't delete a file mid-share), chip scrolling/wrapping, tombstone deletes + Undo (verified the shared Snackbar/scope genuinely survives the triggering screen popping, and soft-deletes are filtered everywhere they need to be), FlightLegs connecting/return-leg matching (verified against the iOS reference, UTC-safe), extra-field serialization and the documented choice-field departure, leg actions' carry-over of aircraft/people/nature/reason, arrival-follows-departure UTC-day sync, past-flight collapsing, email export's FileProvider usage and mailto-selector/fallback logic, and the sign-in-redirect-dedup fix (its Bundle-backed handledCallback genuinely survives both rotation and process death) - all matched their described intent with no issues found.

@roznet
roznet marked this pull request as ready for review September 26, 2026 12:18
@roznet
roznet merged commit bc9b71f into main Sep 26, 2026
2 of 3 checks passed
@roznet
roznet deleted the claude/eager-cori-hiygcs branch September 26, 2026 12:18
roznet added a commit that referenced this pull request Sep 26, 2026
…on had expired

Follow-up to #33's review. A 401 from DELETE /auth/account is caught by the
shared interceptor, which drops the token, so the sign-in screen replaced
Settings before its error could show and the pilot never learnt the account
was still there. AuthService now holds a sign-in notice that the sign-in
screen shows until the next sign-in.

Also from the review, in the parity tracker: PR 1 merged with its unit and
instrumented tests run, the draft-vs-process-death limit accepted, and the
responsible person's `contact` kept as iOS stores it (phone), with
designs/ios-app.md corrected to match the code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android parity 1/3: forms come out right and complete

2 participants