Skip to content

feat: make erase real (zero the drive's partition structures)#6

Open
jessejamesjohnston wants to merge 30 commits into
kuscher:mainfrom
jessejamesjohnston:feat/real-erase
Open

feat: make erase real (zero the drive's partition structures)#6
jessejamesjohnston wants to merge 30 commits into
kuscher:mainfrom
jessejamesjohnston:feat/real-erase

Conversation

@jessejamesjohnston

Copy link
Copy Markdown

Stacked series — PR 6 of 8. Stacked on #5 — please review/merge #5 first. Diff shown against main includes the earlier PRs until they merge. Series order: cleanup → toolchain → adaptive → expressive → verification → real-erase → dino → playful-status.


feat: make erase real

Stacked on feat/flash-verification — review that first.
Touches the SCSI path (one new command). Needs the OTG hardware pass.

Summary

Every "erase" in the app was simulated: EraseScreen, the erase-before-flash
option, and "Cancel and reset USB Stick" all animated a progress bar for
three seconds, wrote nothing, and reported success. The cancel path was the
worst case — it declared "Your USB has been reset" while leaving a
half-written recovery image on the drive.

One commit makes all three paths real:

  • UsbFlasher.eraseDevice() zeroes the first 16 MiB (MBR/primary GPT +
    early filesystem metadata) and, when the drive's capacity is readable, the
    last 1 MiB (backup GPT). The OS then sees a genuinely blank drive. ~17 MiB
    ≈ seconds, so no foreground service needed.
  • BotDevice.readCapacity() — SCSI READ CAPACITY(10), the one new
    command, needed to locate the backup GPT. Built with the same
    scratch-buffer CSW discipline as the hardened READ/WRITE paths. If it
    fails, the erase degrades to head-only (still unmountable) rather than
    failing.
  • Sequencing on the cancel path: the reset-erase starts only after the
    flasher reports Cancelled — the write loop must release the USB
    interface before the erase claims it (single owner on the connection).
  • EraseViewModel brings the standalone erase flow up to the same
    architecture as the flash flow (state machine, config-change survival,
    pure-renderer screen) and finally uses the device parameter — closing
    out the last compiler warning in the project.
  • Honest copy: the erase-first dialog no longer claims to "format", and
    the success screen says what actually happened (partition information
    cleared; the OS may offer to format the drive).

Scope notes

This is a signature wipe, not data destruction — same as the desktop
Chromebook Recovery Utility's erase, except CRU also reformats to FAT32
afterwards so the drive is immediately usable. A FAT32 writer is a
meaningful chunk of filesystem code; deferred, and the success copy is
honest about it.

Testing

  • Builds, lint, and unit tests clean; the erase flow renders correctly on
    the emulator (wavy progress → success badge) — verified in the Expressive
    PR with this same UI.
  • NEEDS OTG HARDWARE:
    1. Erase a previously flashed stick → computer should see a blank drive
      and offer to format (no ChromeOS recovery volumes mounting).
    2. Cancel a flash mid-write → after the automatic reset-erase, the drive
      must not mount as (broken) recovery media.
    3. Erase-first flow → erase completes, flash proceeds on the re-claimed
      interface.

Hardware validation

Verified on real devices over USB-OTG (wireless ADB): Pixel 10 Pro XL (phone) and Pixel Tablet (API 37).

  • Standalone erase on the tablet's SABRENT drive completed with the honest summary ("The drive's partition information was cleared. Your computer may ask to format it…"). The drive read back as blank afterward — this is real partition-structure zeroing (READ CAPACITY(10) + head/tail wipe), replacing the previous 3-second fake animation.
  • The overflow "Erase recovery media" action is hidden on the flash/erase screens, so a running operation can't be interrupted from the menu (fixed during hardware testing).
  • The erase-first-then-flash path re-claims the USB interface correctly after the erase.

test.kt and test_api36.kt were one-off probes for the API 36
Notification.ProgressStyle / setShortCriticalText APIs. They live outside
any source set, so they never compiled into the app; delete them to keep
the module root clean.
FlashScreen.kt in particular had ~38 fully-qualified references
(androidx.compose.ui.platform.LocalLifecycleOwner, android.app.PendingIntent,
and friends) inline in the code. Promote them all to proper imports in
FlashScreen, RecoveryApp, SelectDriveScreen, SelectModelScreen, UsbFlasher,
and KeepAliveService so the code reads like idiomatic Kotlin.

No behavior change; the compiled output is identical.
The navigation graph, the top-bar step indicator, and every navigate() call
previously agreed on eight string literals by convention alone. A typo in
any one of them would compile fine and fail at runtime.

Add a sealed Route hierarchy in ui/Route.kt as the single source of truth:
each destination exposes its route pattern, and SelectChannel (the only
parameterized destination) exposes forModel() so the URL-encoding of the
model-name argument lives in exactly one place.

No behavior change; the route strings are byte-for-byte the same.
FlashScreen was a 353-line composable that managed the Foreground Service,
built API-36 Live Update notifications, ran the erase simulation, drove
UsbFlasher, and rendered the UI, all inline. Business logic in the view
layer meant no state survival story of its own and no way to unit test the
flow.

Split it three ways:
- FlashViewModel owns the state machine (erasing -> flashing ->
  success/error, plus the cancel-and-reset path) and exposes a single
  FlashUiState via StateFlow. Scoped to the flash NavBackStackEntry, so
  the running flash now survives configuration changes by design rather
  than relying on the activity's configChanges opt-out.
- FlashNotificationController owns the notification channel, the
  KeepAliveService start/stop, and the Live Update / NotificationCompat
  builders. The ViewModel decides when to notify, never how.
- FlashScreen renders FlashUiState. The only view concerns left are the
  POST_NOTIFICATIONS permission prompt (needs an Activity) and
  lifecycle/window-focus tracking (needs a View), both reported to the
  ViewModel.

Also names the flasher's 'Cancelled' sentinel (UsbFlasher.RESULT_CANCELLED)
instead of comparing a bare string literal across files.

Behavior is unchanged: same state transitions, same notification timing and
content, same service lifetime.
Roughly half the user-facing copy lived in strings.xml (the flasher's step
and error messages); the other half was hardcoded in composables, the
notification builders, and the flash state machine. Move all of it to
resources so the copy is localizable and reviewable in one place:

- Screen copy for Welcome, Identify, SelectModel, SelectChannel,
  SelectDrive, Erase, and Flash.
- Notification titles, channel name, and the rotating fun-words/status-chip
  pairs (now index-aligned <string-array>s).
- Shared actions (Continue, OK, Back to Home) deduplicated.

Two deliberate non-changes, noted for review: the copy is byte-for-byte
identical (including the existing mix of ASCII '...' and typographic
ellipsis, worth a separate copy pass), and the notification percentage
keeps its explicit Locale.US formatting rather than picking up the
resource locale, to avoid a behavior change in this PR.
- Drop the Log.d progress line that fired every 500ms during the entire
  write, and the Log.d in SelectDriveScreen's permission-denied path (the
  denial already surfaces to the user via the error dialog). Log.i/Log.e
  diagnostics stay.
- Fix the remaining Kotlin compiler warnings: deprecated
  LinearProgressIndicator overload in EraseScreen, a redundant
  displayName initializer, and an unnecessary non-null assertion.

The build is now warning-free except for EraseScreen's unused 'device'
parameter, which is kept deliberately: the erase flow is currently
simulated and a follow-up will make it operate on the real device.
gradle.properties already declares kotlin.code.style=official; this makes
editors and IDEs enforce the same conventions (4-space indent, UTF-8, LF,
final newline) without depending on per-machine IDE settings.
Runs assembleDebug and Android lint on every PR and on pushes to main,
uploading the lint HTML report as an artifact when the build fails. Both
tasks pass clean on the current tree. No secrets required.
select_channel was never included in the top bar's step mapping, so the
channel screen fell through to the else branch and displayed 'Step 1 of 4'.
Model identification and channel choice are both part of picking the image,
so it joins identify/select_model as step 2.
AGP 8.3.2 -> 9.2.1 (Gradle 8.6 -> 9.4.1), Kotlin 1.9.22 -> AGP 9's built-in
Kotlin with the Compose Compiler Gradle plugin 2.4.0, Compose BOM
2024.04.01 -> 2026.06.01 (material3 1.4.0), compileSdk 36 -> 37 (required
by androidx.core 1.19.0; targetSdk stays 36), Java target 1.8 -> 17, and
current stable AndroidX/coroutines/gson versions.

Notes for review:
- AGP 9 bundles Kotlin, so org.jetbrains.kotlin.android is no longer
  applied and the kotlinOptions/composeOptions blocks are gone
  (kotlinCompilerExtensionVersion is obsolete since Kotlin 2.0).
- material3 no longer depends on material-icons transitively; the frozen
  1.7.8 icons-core artifact is now declared explicitly.
- android.suppressUnsupportedCompileSdk=36 removed (AGP 9 knows API 36/37).
- android:usesCleartextTraffic dropped: both manifest endpoints and all
  image downloads are HTTPS, so the app never needed cleartext.

This intentionally stops at material3 1.4.0 stable. The M3 Expressive
work in a later PR requires the 1.5.0-alpha track and will pin it
explicitly there.
- LocalLifecycleOwner moved from compose-ui to lifecycle-runtime-compose.
- Modifier.menuAnchor() now requires an ExposedDropdownMenuAnchorType;
  both dropdowns are editable primary anchors.
- K2's smarter flow analysis proves dataStream non-null at first use, so
  the remaining !! assertion is now flagged and removed.
compose-ui's new LocalContextGetResourceValueCall lint check (an error by
default) forbids resolving resource strings through LocalContext.current
in composition — it bypasses configuration-change awareness. Keep the
resource ID in state and resolve it at the Text call with stringResource,
which also drops the screen's only LocalContext dependency.
IdentifyScreen, SelectModelScreen, and SelectChannelScreen each constructed
their own RecoveryRepository and re-downloaded the ~2,500-entry manifest
(two JSON endpoints) every time they entered composition. Make the
repository a process-wide singleton with a mutex-guarded cache: first
caller fetches, everyone else reuses. Empty results (fetch failures) are
deliberately not cached so a screen entered after connectivity returns
retries.

Groundwork for the adaptive model-selection layout, where a list pane and a
detail pane both need the manifest at the same time.
The activity previously opted out of recreation for orientation, screen
size, layout, and uiMode changes via android:configChanges — the classic
workaround for state living in composition. With flash state in a
ViewModel (PR: cleanup) the remaining wizard state is three values, so:

- selectedUrl / selectedDevice / eraseFirst move to rememberSaveable
  (UsbDevice is Parcelable), surviving recreation AND process death —
  configChanges never protected against the latter.
- The configChanges attribute is removed; the app now recreates like any
  resizable-first app, picking up resource changes (density, dark mode,
  locale) correctly.

This is the state-preservation groundwork the adaptive layouts in this PR
rely on across fold/unfold and window resize.
The wizard screens are single-column forms; on tablets, foldables, and
desktop windows they previously stretched text fields and body copy across
the full window. Add Modifier.wizardContentWidth() — fill, cap at 600dp,
center — and use it as every screen's root modifier. Phones and any window
under 600dp lay out exactly as before.

Follows the canonical-layouts guidance on constraining single-pane content
on expanded window classes.
On expanded window classes (>=840dp: tablets, desktop windows, unfolded
foldables) SelectModelScreen now uses ListDetailPaneScaffold via
NavigableListDetailPaneScaffold: a searchable model list in the list pane,
and the chosen model's details plus its release channels in the detail
pane. Picking a channel jumps straight to drive selection — the separate
channel screen is a compact-width affordance, not a step large screens
need.

Compact and medium widths keep the existing dropdown flow and separate
channel step, unchanged.

Notes:
- Window class read via currentWindowAdaptiveInfo() + the window-core
  WindowSizeClass breakpoint API (the WindowWidthSizeClass enum era APIs
  are deprecated).
- ListDetailPaneScaffold APIs are @ExperimentalMaterial3AdaptiveApi on
  adaptive 1.2.0 (current stable); flagged here per project policy.
  Navigator calls are suspend (they animate), hence the coroutine scope.
- material3-adaptive-navigation-suite is deliberately not added: it serves
  apps with top-level destinations; a linear wizard has none.
The list was a one-shot snapshot taken when the screen composed (the code
even said so), yet the natural order of operations is reaching this screen
first and plugging the drive in second — which previously required backing
out and re-entering. Listen for the system's USB attach/detach broadcasts
while the screen is visible, refresh the list, and clear a selection whose
device was unplugged.

Registered RECEIVER_NOT_EXPORTED: system broadcasts are still delivered;
the flag only shuts out other apps.
- IdentifyScreen: the model field's Done/Enter action submits through the
  same path as the Continue button, so identify-and-advance works without
  touching the screen.
- Model list search pane: Search/Enter dismisses the soft keyboard so the
  results underneath are visible.

Button focus, activation (Enter/Space), and hover states come from
Compose's Material defaults and need no code here.
- material3 pinned to 1.5.0-alpha23 (overriding the BOM's 1.4.0): the
  Expressive APIs were removed from the 1.4 line at beta01 and live on the
  1.5.0-alpha track. MaterialExpressiveTheme and the wavy indicators used
  later in this PR are promoted (non-experimental) there; the remaining
  experimental usages are opted in per file and listed in the PR.
- ChromebookRecoveryTheme switches MaterialTheme -> MaterialExpressiveTheme,
  keeping the existing (dynamic-color-aware) color schemes while adopting
  the expressive MotionScheme and shape defaults.
- Typography becomes the stock scale, which now carries the 15 Emphasized
  styles. The old hand-rolled styles matched the defaults except
  titleLarge's weight (500 -> spec 400) — a deliberate, tiny visual change.
- ListItem migrated off an overload the alpha deprecates.
- The flash and erase progress bars become LinearWavyProgressIndicator —
  the signature Expressive component. Progress animates on the
  WavyProgressIndicatorDefaults.ProgressAnimationSpec so the flasher's
  ~500ms discrete callbacks glide, and the amplitude lambda settles the
  wave flat as progress reaches 100%.
- Indeterminate manifest-loading spinners (model list, dropdowns, channel
  screen) become the morphing-polygon LoadingIndicator.

LinearWavyProgressIndicator is promoted (non-experimental) on
material3 1.5.0-alpha23; LoadingIndicator is still
@ExperimentalMaterial3ExpressiveApi and opted in where used.
Success gets the flow's one moment of celebration: a SoftBurst polygon
morphing into a circle around the checkmark, springing in on the theme's
expressive motion scheme. Built exactly the way Material sanctions shape
morphing — MaterialShapes polygons + a graphics-shapes Morph + an animated
float (the same construction LoadingIndicator uses internally); the morph
fraction is clamped so spring overshoot never produces an undefined shape.

Errors deliberately get the opposite: a static soft container in
error-container colors. A failed flash should read 'stop and read this',
not alarm the user with motion.

Both flash and erase terminal states use the badges, and terminal text
steps up to the titleLargeEmphasized Expressive style.

MaterialShapes / toShape() / Morph.toPath() are still
@ExperimentalMaterial3ExpressiveApi on 1.5.0-alpha23; opted in, in one file.
Forward navigation slides the next step in on the motion scheme's fast
spatial (spring) spec while the outgoing step fades on the effects spec;
back navigation mirrors it. Reading the specs from
MaterialTheme.motionScheme (supplied by MaterialExpressiveTheme) means the
wizard's motion personality is themed, not hardcoded — swap the scheme and
every transition follows.
- Subtle ContextClick on every step advance (welcome, identify submit,
  channel choice in both compact and detail-pane flows, model continue).
- ToggleOn when a drive is selected — a state change, not a navigation.
- A distinct terminal pattern on the flash screen: Confirm on success,
  Reject on failure (and Confirm when an erase completes).

All feedback goes through LocalHapticFeedback, which routes through
View.performHapticFeedback and therefore respects the system's
touch-feedback setting.
Get Started adopts ButtonDefaults.shapes(), the Expressive press-morph
shape pair. The welcome screen is the only step whose primary action sits
alone (every other step's actions are context-dependent), which makes it
the right place to show the expressive button treatment without noise.
IncrementalDigest wraps MessageDigest with the lowercase-hex rendering the
recovery manifest uses and a case-insensitive comparison helper.
BoundedDigest digests exactly N bytes from offered chunks — the read-back
path works in whole 512-byte sectors, so the final chunk carries padding
that must not enter the digest.

Pure Kotlin by design (no Android/USB types) so the correctness core of
the upcoming verification feature is covered by plain JVM tests: FIPS/RFC
test vectors, chunking equivalence, bound behavior, and flipped-byte
detection. First unit tests in the project.
Two latent defects in the SCSI transfer code, exposed now that the read
path is about to carry gigabytes of verification traffic instead of a
64KB smoke test:

- Data phase: a short bulk IN transfer (legal at packet boundaries) failed
  the entire READ(10). Accumulate and continue instead.
- CSW phase (both commands): bulkTransfer always writes at the buffer
  start, so the partial-read 'shift' logic overwrote bytes already
  collected. Accumulate through a scratch buffer.

Both are behavior-preserving on the happy path (full-size transfers land
identically) and only change what happens on partial transfers, which
previously produced spurious failures or corrupted status reads.

NEEDS OTG HARDWARE TESTING alongside the verification feature.
Replaces the token 64KB readability check with real verification, on by
default:

- Authenticity (official images): the raw download is hashed in flight
  (DigestingInputStream under the on-the-fly unzip) and, after the write,
  the stream is drained and the digest compared against the manifest's
  sha1 — which is the checksum OF THE ZIP, verified empirically against
  dl.google.com. A mismatch means the download was corrupt and the drive
  can't be trusted.
- Write verification (all images): the flasher digests the decompressed
  bytes as written, tracks the exact written length, then reads every
  written sector back via SCSI READ(10) in the same 64KB chunk discipline
  and compares digests (BoundedDigest keeps final-sector padding out).
  Local images get this labeled as write verification — there is no
  authority to check authenticity against.
- UsbFlasher returns a sealed Result (Success/Cancelled/WriteError/
  VerificationError) instead of a nullable string with a 'Cancelled'
  sentinel. Success carries the verification level; the UI copy
  distinguishes checksum-verified, write-verified, and skipped.
- The FlashViewModel state machine grows a verifying phase with its own
  honest progress (read-back takes about as long as the write), a Skip
  button (skip != fail), and Retry on verification failure. The Live
  Update notification narrates the phase factually.
- Progress accuracy: the manifest's exact uncompressed filesize is plumbed
  through the wizard, replacing the 3x-compressed guess; and
  contentLengthLong fixes the Int overflow on >2GB downloads.
- Leaving the flash screen now cancels the flasher cooperatively
  (onCleared) — the write loop checks the flag every 64KB, and the finally
  block releases the USB interface, so cancellation mid-verification
  cannot wedge the connection. OS-backgrounding still keeps flashing via
  the foreground service; only an explicit in-app exit aborts.

Digest logic is unit-tested (previous commit); the USB paths NEED OTG
HARDWARE TESTING, including an intentional-corruption run documented in
the PR.
Every erase in the app was fake: EraseScreen, the erase-first option, and
'Cancel and reset USB Stick' all played a 3-second progress animation,
wrote nothing, and reported success — the cancel path even claimed 'Your
USB has been reset' while leaving a half-written image on the drive.

Now all three paths run UsbFlasher.eraseDevice(): zero the first 16 MiB
(MBR/primary GPT and early filesystem metadata) and, when READ CAPACITY(10)
succeeds (new BotDevice command), the last 1 MiB (backup GPT). Operating
systems then see a genuinely blank drive. This matches what the desktop
Chromebook Recovery Utility's erase does, minus its FAT32 reformat — noted
as a follow-up. The wipe is ~17 MiB, i.e. seconds, so it runs without the
foreground service.

Details:
- The cancel-reset erase starts only after the flasher reports Cancelled,
  because the write loop must release the USB interface before the erase
  can claim it (single-owner discipline on the connection).
- EraseScreen gets an EraseViewModel (state machine + config-change
  survival) and becomes a pure renderer; its previously unused 'device'
  parameter is now genuinely used, closing out the last compiler warning.
- Success copy is honest about what happened: partition information was
  cleared and the OS may ask to format the drive.
- claimMassStorage() extracts the open/find/claim sequence shared by
  flashing and erasing.

NEEDS OTG HARDWARE TESTING: erase a previously flashed stick, confirm the
OS sees a blank drive; cancel a flash mid-write and confirm the drive no
longer mounts as recovery media afterwards.
The overflow menu's 'Erase recovery media' item stayed visible on the
flash and erase-flash screens. Now that flashing and erasing perform real,
destructive USB writes, navigating away mid-operation via that item would
abandon a write or wipe in progress. Hide the whole overflow menu on the
Flash and EraseFlash routes; it remains on every setup screen.
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.

1 participant