feat(onboarding): require GPL-3.0 license agreement as the first screen #196

Merged
JMR-dev merged 1 commits from feat-172-gpl-license-onboarding into main 2026-07-03 04:08:47 +00:00
JMR-dev commented 2026-07-03 03:57:12 +00:00 (Migrated from github.com)

Summary

  • New Routes.ONBOARDING_LICENSE, now the onboardingGraph's start destination in ui/LibreMailApp.kt — but only when SettingsRepository.licenseAccepted is false; an already-accepted user still lands on ONBOARDING_WELCOME directly, exactly like before this ticket.
  • New ui/onboarding/LicenseScreen.kt: the full GPL-3.0 text, scrollable, with pinned Agree/Decline buttons below it.
    • Agree is disabled until scrolled to the end, gated on scrollState.value >= scrollState.maxValue (the same rememberScrollState() + Modifier.verticalScroll primitives AppPasswordSetupScreen.kt/ManualSetupScreen.kt/ReportReviewScreen.kt use — just scoped to the text pane instead of the whole screen, so the buttons stay visible the whole time rather than being the last item you'd have to scroll past to even see).
    • Decline calls Activity.finish() via LocalActivity.current (MainActivity is this app's only Activity) — no confirmation dialog, immediate exit.
    • A BackHandler wires the system back gesture/button to the same exit path as Decline, since this is now the graph's actual start destination and there is nothing before it to return to.
  • Bundled the license text at app/src/main/res/raw/license.txt, copied from the repo-root LICENSE.
  • Persists acceptance via a new SettingsRepository.licenseAccepted: Boolean (new AppSettings field + Keys.LICENSE_ACCEPTED + setLicenseAccepted(), following the existing pattern), written by a new OnboardingViewModel.markLicenseAccepted() (mirrors markBatteryPromptHandled()/markContactsPromptHandled()). AppViewModel now also resolves this once at launch (same "hold render until known, decide once" contract as its existing startDestination), so LibreMailApp can pick the onboarding graph's actual start destination before building the NavHost.
  • NotificationPermissionEffect() needed no code changes — it already lives solely inside OnboardingWelcomeScreen.kt's own composition, so it already only fires when that screen (now the second onboarding screen) loads. Updated the surrounding comments in OnboardingWelcomeScreen.kt and Routes.kt to explain the new sequencing and explicitly flag the #151 coordination, without touching #151 itself.

Risk flagged: two copies of the license text

res/raw/license.txt and the repo-root LICENSE are now two copies of the same text that can
drift out of sync. I deliberately did not add a build step to copy one into the other (to avoid
touching this repo's already-nontrivial AGP 9 / built-in-Kotlin build config for a one-off), and I
also deliberately did not embed a cross-reference comment inside license.txt itself: this
screen renders that resource verbatim with no reflow/strip logic, so any header or footer baked into
the copy would show up to the user as if it were part of the license they're agreeing to — not
acceptable for a legal-agreement screen. Instead:

  • license.txt stays a byte-for-byte copy of LICENSE (verified with diff before committing).
  • The cross-reference lives in the README.md "License" section (now explicitly says to update
    license.txt when LICENSE changes, and vice versa) and in LicenseScreen.kt's KDoc.
  • This is a manual-sync contract, not an enforced one — a future LICENSE edit that misses the
    license.txt copy would not fail CI. Flagging per the issue's request; happy to add an enforcing
    step (e.g. a CI diff check) as a follow-up if wanted.

Test plan

  • app/src/androidTest/kotlin/org/libremail/ui/onboarding/LicenseScreenTest.kt (new, compiled only
    per repo convention — not run on-device here): the real bundled GPL text renders; Agree starts
    disabled and becomes enabled (and invokes the callback) only after scrolling to a sentinel at the
    true end of the text; Decline invokes its callback without any scrolling; system back invokes the
    same callback as Decline.
  • app/src/test/kotlin/org/libremail/ui/AppViewModelTest.kt (new): startDestination still resolves
    onboarding-vs-mailbox from the account count; the new licenseAccepted resolves from
    SettingsRepository.settings (mocked).
  • app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt: added default/round-trip/
    fallback coverage for licenseAccepted, mirroring the existing fetchPolicy cases.
  • app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt: added a case for
    markLicenseAccepted() persisting the flag, mirroring the existing battery/contacts cases.
  • Added a comment to the existing OnboardingFlowTest.kt explaining why its hand-rolled nav graph
    still starts at ONBOARDING_WELCOME directly (it owns the picker → setup → finish tail, not the
    license screen's own contract, which LicenseScreenTest covers).
  • :app:assembleDebug
  • :app:testDebugUnitTest
  • :app:lintDebug
  • :app:ktlintCheck :app:detekt
  • :app:compileDebugAndroidTestKotlin

Closes #172

🤖 Generated with Claude Code

## Summary - New `Routes.ONBOARDING_LICENSE`, now the `onboardingGraph`'s start destination in `ui/LibreMailApp.kt` — but only when `SettingsRepository.licenseAccepted` is false; an already-accepted user still lands on `ONBOARDING_WELCOME` directly, exactly like before this ticket. - New `ui/onboarding/LicenseScreen.kt`: the full GPL-3.0 text, scrollable, with pinned Agree/Decline buttons below it. - **Agree** is disabled until scrolled to the end, gated on `scrollState.value >= scrollState.maxValue` (the same `rememberScrollState()` + `Modifier.verticalScroll` primitives `AppPasswordSetupScreen.kt`/`ManualSetupScreen.kt`/`ReportReviewScreen.kt` use — just scoped to the text pane instead of the whole screen, so the buttons stay visible the whole time rather than being the last item you'd have to scroll past to even see). - **Decline** calls `Activity.finish()` via `LocalActivity.current` (MainActivity is this app's only Activity) — no confirmation dialog, immediate exit. - A `BackHandler` wires the system back gesture/button to the same exit path as Decline, since this is now the graph's actual start destination and there is nothing before it to return to. - Bundled the license text at `app/src/main/res/raw/license.txt`, copied from the repo-root `LICENSE`. - Persists acceptance via a new `SettingsRepository.licenseAccepted: Boolean` (new `AppSettings` field + `Keys.LICENSE_ACCEPTED` + `setLicenseAccepted()`, following the existing pattern), written by a new `OnboardingViewModel.markLicenseAccepted()` (mirrors `markBatteryPromptHandled()`/`markContactsPromptHandled()`). `AppViewModel` now also resolves this once at launch (same "hold render until known, decide once" contract as its existing `startDestination`), so `LibreMailApp` can pick the onboarding graph's actual start destination before building the `NavHost`. - `NotificationPermissionEffect()` needed no code changes — it already lives solely inside `OnboardingWelcomeScreen.kt`'s own composition, so it already only fires when that screen (now the *second* onboarding screen) loads. Updated the surrounding comments in `OnboardingWelcomeScreen.kt` and `Routes.kt` to explain the new sequencing and explicitly flag the #151 coordination, without touching #151 itself. ## Risk flagged: two copies of the license text `res/raw/license.txt` and the repo-root `LICENSE` are now two copies of the same text that can drift out of sync. I deliberately did **not** add a build step to copy one into the other (to avoid touching this repo's already-nontrivial AGP 9 / built-in-Kotlin build config for a one-off), and I also deliberately did **not** embed a cross-reference comment inside `license.txt` itself: this screen renders that resource verbatim with no reflow/strip logic, so any header or footer baked into the copy would show up to the user as if it were part of the license they're agreeing to — not acceptable for a legal-agreement screen. Instead: - `license.txt` stays a byte-for-byte copy of `LICENSE` (verified with `diff` before committing). - The cross-reference lives in the **README.md "License" section** (now explicitly says to update `license.txt` when `LICENSE` changes, and vice versa) and in `LicenseScreen.kt`'s KDoc. - This is a manual-sync contract, not an enforced one — a future `LICENSE` edit that misses the `license.txt` copy would not fail CI. Flagging per the issue's request; happy to add an enforcing step (e.g. a CI diff check) as a follow-up if wanted. ## Test plan - `app/src/androidTest/kotlin/org/libremail/ui/onboarding/LicenseScreenTest.kt` (new, compiled only per repo convention — not run on-device here): the real bundled GPL text renders; Agree starts disabled and becomes enabled (and invokes the callback) only after scrolling to a sentinel at the true end of the text; Decline invokes its callback without any scrolling; system back invokes the same callback as Decline. - `app/src/test/kotlin/org/libremail/ui/AppViewModelTest.kt` (new): `startDestination` still resolves onboarding-vs-mailbox from the account count; the new `licenseAccepted` resolves from `SettingsRepository.settings` (mocked). - `app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt`: added default/round-trip/ fallback coverage for `licenseAccepted`, mirroring the existing `fetchPolicy` cases. - `app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt`: added a case for `markLicenseAccepted()` persisting the flag, mirroring the existing battery/contacts cases. - Added a comment to the existing `OnboardingFlowTest.kt` explaining why its hand-rolled nav graph still starts at `ONBOARDING_WELCOME` directly (it owns the picker → setup → finish tail, not the license screen's own contract, which `LicenseScreenTest` covers). - [x] `:app:assembleDebug` - [x] `:app:testDebugUnitTest` - [x] `:app:lintDebug` - [x] `:app:ktlintCheck :app:detekt` - [x] `:app:compileDebugAndroidTestKotlin` Closes #172 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.