feat: make erase real (zero the drive's partition structures)#6
Open
jessejamesjohnston wants to merge 30 commits into
Open
feat: make erase real (zero the drive's partition structures)#6jessejamesjohnston wants to merge 30 commits into
jessejamesjohnston wants to merge 30 commits into
Conversation
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.
This was referenced Jul 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: make erase real
Summary
Every "erase" in the app was simulated:
EraseScreen, the erase-before-flashoption, 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 newcommand, 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.
flasher reports
Cancelled— the write loop must release the USBinterface before the erase claims it (single owner on the connection).
EraseViewModelbrings the standalone erase flow up to the samearchitecture as the flash flow (state machine, config-change survival,
pure-renderer screen) and finally uses the
deviceparameter — closingout the last compiler warning in the project.
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
the emulator (wavy progress → success badge) — verified in the Expressive
PR with this same UI.
and offer to format (no ChromeOS recovery volumes mounting).
must not mount as (broken) recovery media.
interface.
Hardware validation
Verified on real devices over USB-OTG (wireless ADB): Pixel 10 Pro XL (phone) and Pixel Tablet (API 37).