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).
## 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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Routes.ONBOARDING_LICENSE, now theonboardingGraph's start destination inui/LibreMailApp.kt— but only whenSettingsRepository.licenseAcceptedis false; an already-accepted user still lands onONBOARDING_WELCOMEdirectly, exactly like before this ticket.ui/onboarding/LicenseScreen.kt: the full GPL-3.0 text, scrollable, with pinned Agree/Decline buttons below it.scrollState.value >= scrollState.maxValue(the samerememberScrollState()+Modifier.verticalScrollprimitivesAppPasswordSetupScreen.kt/ManualSetupScreen.kt/ReportReviewScreen.ktuse — 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).Activity.finish()viaLocalActivity.current(MainActivity is this app's only Activity) — no confirmation dialog, immediate exit.BackHandlerwires 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.app/src/main/res/raw/license.txt, copied from the repo-rootLICENSE.SettingsRepository.licenseAccepted: Boolean(newAppSettingsfield +Keys.LICENSE_ACCEPTED+setLicenseAccepted(), following the existing pattern), written by a newOnboardingViewModel.markLicenseAccepted()(mirrorsmarkBatteryPromptHandled()/markContactsPromptHandled()).AppViewModelnow also resolves this once at launch (same "hold render until known, decide once" contract as its existingstartDestination), soLibreMailAppcan pick the onboarding graph's actual start destination before building theNavHost.NotificationPermissionEffect()needed no code changes — it already lives solely insideOnboardingWelcomeScreen.kt's own composition, so it already only fires when that screen (now the second onboarding screen) loads. Updated the surrounding comments inOnboardingWelcomeScreen.ktandRoutes.ktto 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.txtand the repo-rootLICENSEare now two copies of the same text that candrift 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.txtitself: thisscreen 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.txtstays a byte-for-byte copy ofLICENSE(verified withdiffbefore committing).license.txtwhenLICENSEchanges, and vice versa) and inLicenseScreen.kt's KDoc.LICENSEedit that misses thelicense.txtcopy would not fail CI. Flagging per the issue's request; happy to add an enforcingstep (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 onlyper 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):startDestinationstill resolvesonboarding-vs-mailbox from the account count; the new
licenseAcceptedresolves fromSettingsRepository.settings(mocked).app/src/test/kotlin/org/libremail/data/settings/AppSettingsTest.kt: added default/round-trip/fallback coverage for
licenseAccepted, mirroring the existingfetchPolicycases.app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt: added a case formarkLicenseAccepted()persisting the flag, mirroring the existing battery/contacts cases.OnboardingFlowTest.ktexplaining why its hand-rolled nav graphstill starts at
ONBOARDING_WELCOMEdirectly (it owns the picker → setup → finish tail, not thelicense screen's own contract, which
LicenseScreenTestcovers).:app:assembleDebug:app:testDebugUnitTest:app:lintDebug:app:ktlintCheck :app:detekt:app:compileDebugAndroidTestKotlinCloses #172
🤖 Generated with Claude Code