diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f6cf0e9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# Kotlin official code style (matches kotlin.code.style=official in gradle.properties) +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +max_line_length = 120 + +[*.{xml,yml,yaml}] +indent_size = 4 +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..d5d9d0d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,28 @@ +name: Android CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 + - name: Build and lint + run: ./gradlew assembleDebug lint + - name: Upload lint report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: android/app/build/reports/lint-results-debug.html diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bfa1f87..7b75372 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,11 +1,11 @@ plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") } android { namespace = "com.google.chrome.recovery" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.google.chrome.recovery" @@ -30,18 +30,12 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.8" - } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -51,28 +45,43 @@ android { dependencies { - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - implementation("androidx.activity:activity-compose:1.8.2") - implementation(platform("androidx.compose:compose-bom:2024.04.01")) + implementation("androidx.core:core-ktx:1.19.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") + implementation("androidx.activity:activity-compose:1.13.0") + implementation(platform("androidx.compose:compose-bom:2026.06.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - + // Pinned past the BOM's 1.4.0: every Material 3 Expressive API (wavy progress, + // LoadingIndicator, MaterialExpressiveTheme, MaterialShapes) lives on the + // 1.5.0-alpha track — Expressive was removed from the 1.4 line at beta01. + implementation("androidx.compose.material3:material3:1.5.0-alpha23") + // material3 stopped depending on material-icons transitively in newer BOMs; + // the icons artifacts are frozen at 1.7.x and are no longer BOM-managed. + implementation("androidx.compose.material:material-icons-core:1.7.8") + // Navigation - implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.navigation:navigation-compose:2.9.8") + + // Adaptive layouts (window size classes + list-detail pane scaffold). + // material3-adaptive-navigation-suite is deliberately absent: it exists for + // apps with top-level destinations (bar/rail/drawer); a linear wizard has none. + implementation("androidx.compose.material3.adaptive:adaptive:1.2.0") + implementation("androidx.compose.material3.adaptive:adaptive-layout:1.2.0") + implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.2.0") // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") // JSON Parsing (Gson or Kotlinx Serialization, let's use Gson for simplicity) - implementation("com.google.code.gson:gson:2.10.1") + implementation("com.google.code.gson:gson:2.14.0") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation(platform("androidx.compose:compose-bom:2024.04.01")) + androidTestImplementation(platform("androidx.compose:compose-bom:2026.06.01")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1858ed2..850ee94 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,14 +17,12 @@ android:icon="@mipmap/ic_launcher" android:label="Book Recovery" android:supportsRtl="true" - android:theme="@style/Theme.ChromebookRecovery" - android:usesCleartextTraffic="true"> + android:theme="@style/Theme.ChromebookRecovery"> diff --git a/android/app/src/main/java/com/google/chrome/recovery/data/RecoveryRepository.kt b/android/app/src/main/java/com/google/chrome/recovery/data/RecoveryRepository.kt index 97a1d3b..7592a10 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/data/RecoveryRepository.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/data/RecoveryRepository.kt @@ -3,6 +3,8 @@ package com.google.chrome.recovery.data import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.InputStreamReader import java.net.HttpURLConnection @@ -17,18 +19,42 @@ import java.net.URL * for the zipped `.bin` payloads. * * It aggregates both standard ChromeOS models and CloudReady (ChromeOS Flex) models. + * + * The manifest is fetched once per process and cached: several wizard screens + * (identify, model selection, channel selection, and the expanded-width detail + * pane) all need the same ~2,500-entry list, and previously each of them + * re-downloaded it. Access the repository through [RecoveryRepository.instance]. */ -class RecoveryRepository { +class RecoveryRepository private constructor() { + + companion object { + /** Process-wide instance so every screen shares one cached manifest. */ + val instance = RecoveryRepository() + } private val RECOVERY_URL = "https://dl.google.com/dl/edgedl/chromeos/recovery/recovery2.json" private val CLOUDREADY_URL = "https://dl.google.com/dl/edgedl/chromeos/recovery/cloudready_recovery2.json" - suspend fun fetchRecoveryImages(): List = withContext(Dispatchers.IO) { + private val mutex = Mutex() + private var cached: List? = null + + suspend fun fetchRecoveryImages(): List = mutex.withLock { + cached?.let { return@withLock it } + val images = fetchFromNetwork() + // An empty result means the fetch failed; don't cache it, so the next + // screen gets another chance once connectivity returns. + if (images.isNotEmpty()) { + cached = images + } + images + } + + private suspend fun fetchFromNetwork(): List = withContext(Dispatchers.IO) { val images = mutableListOf() try { val listType = object : TypeToken>() {}.type val gson = Gson() - + // Fetch normal ChromeOS recovery val url = URL(RECOVERY_URL) val connection = url.openConnection() as HttpURLConnection diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/RecoveryApp.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/RecoveryApp.kt index 9c96bf8..f0bb4a9 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/RecoveryApp.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/RecoveryApp.kt @@ -1,5 +1,12 @@ package com.google.chrome.recovery.ui +import android.hardware.usb.UsbDevice +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -8,6 +15,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.navigation.compose.NavHost @@ -35,16 +43,25 @@ fun RecoveryApp() { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - var selectedUrl by remember { mutableStateOf(null) } - var selectedDevice by remember { mutableStateOf(null) } - var eraseFirst by remember { mutableStateOf(false) } + // Wizard state is saveable so it survives configuration changes (rotation, + // fold/unfold, window resize) and system-initiated process death. UsbDevice + // is Parcelable, so the default saver handles all three. + var selectedUrl by rememberSaveable { mutableStateOf(null) } + var selectedSha1 by rememberSaveable { mutableStateOf(null) } + var selectedImageSize by rememberSaveable { mutableStateOf(null) } + var selectedDevice by rememberSaveable { mutableStateOf(null) } + var eraseFirst by rememberSaveable { mutableStateOf(false) } - val filePickerLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() ) { uri -> if (uri != null) { selectedUrl = uri.toString() - navController.navigate("select_drive") + // Local files carry no manifest checksum; the flash gets write + // verification only. + selectedSha1 = null + selectedImageSize = null + navController.navigate(Route.SelectDrive.pattern) } } @@ -53,15 +70,15 @@ fun RecoveryApp() { TopAppBar( title = { val stepIndex = when (currentRoute) { - "welcome" -> 1 - "identify", "select_model" -> 2 - "select_drive", "erase_drive" -> 3 - "flash", "erase_flash" -> 4 + Route.Welcome.pattern -> 1 + Route.Identify.pattern, Route.SelectModel.pattern, Route.SelectChannel.pattern -> 2 + Route.SelectDrive.pattern, Route.EraseDrive.pattern -> 3 + Route.Flash.pattern, Route.EraseFlash.pattern -> 4 else -> 1 } Column { Text(stringResource(R.string.app_name)) - if (currentRoute != "welcome" && currentRoute != null) { + if (currentRoute != Route.Welcome.pattern && currentRoute != null) { Text( stringResource(R.string.title_step_of, stepIndex, 4), style = MaterialTheme.typography.labelMedium, @@ -77,9 +94,9 @@ fun RecoveryApp() { actionIconContentColor = MaterialTheme.colorScheme.onPrimary ), navigationIcon = { - if (currentRoute != "welcome") { - if (currentRoute == "flash" || currentRoute == "erase_flash") { - IconButton(onClick = { navController.popBackStack("welcome", inclusive = false) }) { + if (currentRoute != Route.Welcome.pattern) { + if (currentRoute == Route.Flash.pattern || currentRoute == Route.EraseFlash.pattern) { + IconButton(onClick = { navController.popBackStack(Route.Welcome.pattern, inclusive = false) }) { Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.action_home)) } } else { @@ -90,106 +107,146 @@ fun RecoveryApp() { } }, actions = { - var expanded by remember { mutableStateOf(false) } - IconButton(onClick = { expanded = true }) { - Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more)) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_erase_media)) }, - onClick = { - expanded = false - navController.navigate("erase_drive") - } - ) + // Hide the erase action while an operation is running (flash or + // erase): navigating away would abandon a real write/wipe in + // progress. It only makes sense on the setup screens. + val isOperationRunning = currentRoute == Route.Flash.pattern || + currentRoute == Route.EraseFlash.pattern + if (!isOperationRunning) { + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { expanded = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more)) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_erase_media)) }, + onClick = { + expanded = false + navController.navigate(Route.EraseDrive.pattern) + } + ) + } } } ) } ) { innerPadding -> + // Wizard steps advance with the theme's expressive motion: forward slides + // in on a fast spatial (spring) spec while the outgoing step fades on the + // effects spec; popping back mirrors it. Spatial specs animate position, + // effects specs animate alpha, per the MotionScheme contract. + val motionScheme = MaterialTheme.motionScheme NavHost( navController = navController, - startDestination = "welcome", - modifier = Modifier.padding(innerPadding) + startDestination = Route.Welcome.pattern, + modifier = Modifier.padding(innerPadding), + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + motionScheme.fastSpatialSpec() + ) + fadeIn(motionScheme.fastEffectsSpec()) + }, + exitTransition = { fadeOut(motionScheme.fastEffectsSpec()) }, + popEnterTransition = { fadeIn(motionScheme.fastEffectsSpec()) }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.End, + motionScheme.fastSpatialSpec() + ) + fadeOut(motionScheme.fastEffectsSpec()) + } ) { - composable("welcome") { - WelcomeScreen(onNext = { navController.navigate("identify") }) + composable(Route.Welcome.pattern) { + WelcomeScreen(onNext = { navController.navigate(Route.Identify.pattern) }) } - composable("identify") { + composable(Route.Identify.pattern) { IdentifyScreen( onNext = { modelName -> - navController.navigate("select_channel/${android.net.Uri.encode(modelName)}") + navController.navigate(Route.SelectChannel.forModel(modelName)) }, onSelectFromList = { - navController.navigate("select_model") + navController.navigate(Route.SelectModel.pattern) }, onSelectLocalImage = { filePickerLauncher.launch(arrayOf("*/*")) } ) } - composable("select_model") { - SelectModelScreen(onNext = { modelName -> - navController.navigate("select_channel/${android.net.Uri.encode(modelName)}") - }) + composable(Route.SelectModel.pattern) { + SelectModelScreen( + onNext = { modelName -> + navController.navigate(Route.SelectChannel.forModel(modelName)) + }, + onImageSelected = { image -> + // Expanded widths pick the channel in the detail pane, so the + // separate channel step is skipped entirely. + selectedUrl = image.url + selectedSha1 = image.sha1 + selectedImageSize = image.filesize + navController.navigate(Route.SelectDrive.pattern) + } + ) } - composable("select_channel/{modelName}") { backStackEntry -> - val modelName = android.net.Uri.decode(backStackEntry.arguments?.getString("modelName") ?: "") + composable(Route.SelectChannel.pattern) { backStackEntry -> + val modelName = Uri.decode(backStackEntry.arguments?.getString(Route.ARG_MODEL_NAME) ?: "") SelectChannelScreen( modelName = modelName, - onNext = { modelUrl -> - selectedUrl = modelUrl - navController.navigate("select_drive") + onNext = { image -> + selectedUrl = image.url + selectedSha1 = image.sha1 + selectedImageSize = image.filesize + navController.navigate(Route.SelectDrive.pattern) } ) } - composable("select_drive") { + composable(Route.SelectDrive.pattern) { SelectDriveScreen( isEraseFlow = false, onNext = { device -> selectedDevice = device eraseFirst = false - navController.navigate("flash") + navController.navigate(Route.Flash.pattern) }, onEraseFirst = { device -> selectedDevice = device eraseFirst = true - navController.navigate("flash") + navController.navigate(Route.Flash.pattern) } ) } - composable("erase_drive") { + composable(Route.EraseDrive.pattern) { SelectDriveScreen( isEraseFlow = true, onNext = { device -> selectedDevice = device - navController.navigate("erase_flash") + navController.navigate(Route.EraseFlash.pattern) } ) } - composable("flash") { + composable(Route.Flash.pattern) { if (selectedUrl != null && selectedDevice != null) { FlashScreen( url = selectedUrl!!, device = selectedDevice!!, + expectedSha1 = selectedSha1, + expectedImageSize = selectedImageSize, eraseFirst = eraseFirst, onFinish = { - navController.popBackStack("welcome", inclusive = false) + navController.popBackStack(Route.Welcome.pattern, inclusive = false) } ) } else { navController.popBackStack() } } - composable("erase_flash") { + composable(Route.EraseFlash.pattern) { if (selectedDevice != null) { EraseScreen( device = selectedDevice!!, onFinish = { - navController.popBackStack("welcome", inclusive = false) + navController.popBackStack(Route.Welcome.pattern, inclusive = false) } ) } else { diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/Route.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/Route.kt new file mode 100644 index 0000000..6d73182 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/Route.kt @@ -0,0 +1,36 @@ +package com.google.chrome.recovery.ui + +import android.net.Uri + +/** + * Type-safe definitions of every destination in the recovery wizard's navigation graph. + * + * Each object carries the route [pattern] registered with the NavHost. Destinations + * without arguments navigate using the pattern directly; [SelectChannel] takes the + * selected model name as a path argument and exposes [SelectChannel.forModel] to build + * a concrete navigation target (URL-encoding the name, since model names can contain + * spaces and slashes). + * + * Centralizing the routes here keeps `RecoveryApp`'s graph, the top-bar step indicator, + * and every `navigate()` call in agreement, instead of scattering string literals that + * can silently drift apart. + */ +sealed class Route(val pattern: String) { + data object Welcome : Route("welcome") + data object Identify : Route("identify") + data object SelectModel : Route("select_model") + data object SelectDrive : Route("select_drive") + data object EraseDrive : Route("erase_drive") + data object Flash : Route("flash") + data object EraseFlash : Route("erase_flash") + + data object SelectChannel : Route("select_channel/{$ARG_MODEL_NAME}") { + /** Builds the concrete route for navigating to the channel list of [modelName]. */ + fun forModel(modelName: String): String = "select_channel/${Uri.encode(modelName)}" + } + + companion object { + /** Name of the model-name path argument used by [SelectChannel]. */ + const val ARG_MODEL_NAME = "modelName" + } +} diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/WizardContentWidth.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/WizardContentWidth.kt new file mode 100644 index 0000000..52bd7ff --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/WizardContentWidth.kt @@ -0,0 +1,31 @@ +package com.google.chrome.recovery.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Maximum width of a wizard step's content column. + * + * The wizard screens are single-column forms; letting text fields and body copy + * stretch across a 1000dp+ tablet or desktop window hurts readability and looks + * unfinished. 600dp matches the compact/medium window-class boundary, so phones + * are unaffected and anything wider gets a centered column. + */ +private val WizardContentMaxWidth = 600.dp + +/** + * Fills the available space, then caps the content at [WizardContentMaxWidth] + * and centers it horizontally. Drop-in replacement for the screens' previous + * `Modifier.fillMaxSize()` root modifier — narrow windows lay out exactly as + * before, per the canonical-layout guidance for feeds/forms on expanded widths. + */ +fun Modifier.wizardContentWidth(): Modifier = + fillMaxSize() + .wrapContentWidth(Alignment.CenterHorizontally) + .widthIn(max = WizardContentMaxWidth) + .fillMaxWidth() diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseScreen.kt index ed01ca1..b8b4b0f 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseScreen.kt @@ -1,64 +1,114 @@ package com.google.chrome.recovery.ui.screens import android.hardware.usb.UsbDevice +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.chrome.recovery.R +import com.google.chrome.recovery.ui.wizardContentWidth +/** + * Renders the standalone erase flow: a real zeroing of the drive's partition + * structures, driven by [EraseViewModel]. The screen is a pure renderer, in + * the same shape as [FlashScreen]. + */ @Composable fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { - var currentStep by remember { mutableStateOf("Erasing...") } - var progress by remember { mutableStateOf(0f) } - var isFinished by remember { mutableStateOf(false) } + val viewModel: EraseViewModel = viewModel { + EraseViewModel( + application = checkNotNull(this[AndroidViewModelFactory.APPLICATION_KEY]), + device = device + ) + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { - // Simulate erasing by writing zeroes (we don't actually write since we don't have block perms) - for (i in 1..100) { - progress = i / 100f - delay(30) // takes about 3 seconds + viewModel.start() + } + + val haptics = LocalHapticFeedback.current + LaunchedEffect(uiState.isFinished) { + if (uiState.isFinished) { + haptics.performHapticFeedback( + if (uiState.hasError) HapticFeedbackType.Reject else HapticFeedbackType.Confirm + ) } - - currentStep = "Success! Your recovery media has been erased." - progress = 1f - isFinished = true } + val animatedProgress by animateFloatAsState( + targetValue = uiState.progress, + animationSpec = WavyProgressIndicatorDefaults.ProgressAnimationSpec, + label = "eraseProgress" + ) + Column( - modifier = Modifier.fillMaxSize().padding(24.dp), + modifier = Modifier.wizardContentWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + if (uiState.isFinished) { + if (!uiState.hasError) { + MorphingSuccessBadge( + contentDescription = null, + modifier = Modifier.padding(bottom = 24.dp) + ) + } else { + ErrorBadge( + contentDescription = stringResource(R.string.flash_error_icon), + modifier = Modifier.padding(bottom = 24.dp) + ) + } + } + Text( - text = currentStep, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 32.dp), + text = uiState.stepText, + style = if (uiState.isFinished) MaterialTheme.typography.titleLargeEmphasized else MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 16.dp), textAlign = TextAlign.Center ) - - if (!isFinished) { - LinearProgressIndicator( - progress = progress, - modifier = Modifier.fillMaxWidth().height(8.dp) + + uiState.detailText?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + if (!uiState.isFinished) { + Spacer(modifier = Modifier.height(16.dp)) + LinearWavyProgressIndicator( + progress = { animatedProgress }, + amplitude = { p -> if (p >= 1f) 0f else 1f }, + modifier = Modifier.fillMaxWidth() ) Text( - text = "${(progress * 100).toInt()}%", + text = stringResource(R.string.flash_progress_percent, (uiState.progress * 100).toInt()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Please do not remove your recovery media.", + text = stringResource(R.string.erase_do_not_remove), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Button(onClick = onFinish) { - Text("Back to Home") + Text(stringResource(R.string.action_back_to_home)) } } } diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseViewModel.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseViewModel.kt new file mode 100644 index 0000000..3b261c1 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/EraseViewModel.kt @@ -0,0 +1,82 @@ +package com.google.chrome.recovery.ui.screens + +import android.app.Application +import android.content.Context +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.chrome.recovery.R +import com.google.chrome.recovery.usb.UsbFlasher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Snapshot of everything [EraseScreen] needs to render. */ +data class EraseUiState( + val stepText: String = "", + val detailText: String? = null, + val progress: Float = 0f, + val isFinished: Boolean = false, + val hasError: Boolean = false, +) + +/** + * ViewModel for the standalone erase flow (the top-bar "Erase recovery media" + * action). Drives [UsbFlasher.eraseDevice] — a real zeroing of the drive's + * partition structures, replacing the previous 3-second animation that wrote + * nothing and then claimed success. + * + * The erase takes seconds, so unlike the flash flow there is no foreground + * service or notification story; the ViewModel just needs to survive + * configuration changes mid-wipe. + */ +class EraseViewModel( + application: Application, + private val device: UsbDevice, +) : AndroidViewModel(application) { + + private val usbManager = application.getSystemService(Context.USB_SERVICE) as UsbManager + private val flasher = UsbFlasher(usbManager, application) + + private val _uiState = MutableStateFlow( + EraseUiState(stepText = application.getString(R.string.erase_step)) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var started = false + + /** Starts the wipe. Idempotent across recomposition and recreation. */ + fun start() { + if (started) return + started = true + + viewModelScope.launch { + val result = flasher.eraseDevice(device) { p -> + _uiState.update { it.copy(progress = p) } + } + when (result) { + is UsbFlasher.EraseResult.Done -> _uiState.update { + it.copy( + stepText = string(R.string.erase_success), + detailText = string(R.string.erase_success_detail), + progress = 1f, + isFinished = true + ) + } + is UsbFlasher.EraseResult.Error -> _uiState.update { + it.copy( + stepText = string(R.string.flash_error, result.message), + isFinished = true, + hasError = true + ) + } + } + } + } + + private fun string(resId: Int, vararg args: Any): String = + getApplication().getString(resId, *args) +} diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashScreen.kt index 4febd26..1aeee32 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashScreen.kt @@ -1,60 +1,97 @@ package com.google.chrome.recovery.ui.screens +import android.Manifest +import android.content.pm.PackageManager +import android.hardware.usb.UsbDevice +import android.os.Build +import android.view.ViewTreeObserver +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.core.animateFloatAsState +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay -import android.content.Context -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbManager +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalContext -import com.google.chrome.recovery.usb.UsbFlasher +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.chrome.recovery.R +import com.google.chrome.recovery.ui.wizardContentWidth /** - * FlashScreen handles the core execution logic of the Book Recovery utility. - * - * This screen is responsible for: - * 1. Tracking and displaying the status of writing the image to the USB device. - * 2. Simulating a device erasure if [eraseFirst] is selected. - * 3. Safely spinning up the [KeepAliveService] (Foreground Service) the moment the screen - * is composed. This elevates the app to a foreground priority, ensuring the OS doesn't - * kill the background write process if the user navigates to their home screen. - * 4. Generating and updating an Android 16 (API 36) Live Update status bar chip using - * `Notification.ProgressStyle()` to keep the user informed natively. + * FlashScreen renders the progress and outcome of the flash flow. + * + * All orchestration — the erase/flash/success/error state machine, the + * [com.google.chrome.recovery.usb.UsbFlasher] lifecycle, the KeepAlive Foreground + * Service, and the Android 16 (API 36) Live Update notification — lives in + * [FlashViewModel]. The screen keeps only the two responsibilities that need the + * view layer: + * 1. Requesting the POST_NOTIFICATIONS runtime permission (needs an Activity). + * 2. Tracking lifecycle state and window focus (needs a View) so the ViewModel + * knows when the user is looking at the app versus relying on the notification. * * @param url The local or remote URL pointing to the recovery image .zip file. * @param device The physical USB device the user has authorized writing to. - * @param eraseFirst If true, the screen will execute a 3-second simulation block before flashing. + * @param eraseFirst If true, a 3-second erase simulation runs before flashing. * @param onFinish Callback invoked when the user dismisses the success/error summary. */ @Composable -fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onFinish: () -> Unit) { - var currentStep by remember { mutableStateOf("Starting...") } - var progress by remember { mutableStateOf(0f) } - var isFinished by remember { mutableStateOf(false) } - var hasError by remember { mutableStateOf(false) } - - var isErasing by remember { mutableStateOf(eraseFirst) } - var isCancelledErase by remember { mutableStateOf(false) } - var isFlashing by remember { mutableStateOf(!eraseFirst) } +fun FlashScreen( + url: String, + device: UsbDevice, + expectedSha1: String? = null, + expectedImageSize: Long? = null, + eraseFirst: Boolean = false, + onFinish: () -> Unit +) { + val viewModel: FlashViewModel = viewModel { + FlashViewModel( + application = checkNotNull(this[AndroidViewModelFactory.APPLICATION_KEY]), + device = device, + url = url, + expectedSha1 = expectedSha1, + expectedImageSize = expectedImageSize, + eraseFirst = eraseFirst + ) + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - val usbManager = remember { context.getSystemService(Context.USB_SERVICE) as UsbManager } - val flasher = remember { UsbFlasher(usbManager, context) } + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) {} + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + viewModel.start() + } + + // The app counts as backgrounded when it is lifecycle-paused OR its window lost + // focus (e.g. hidden behind another freeform window). Either way the user can't + // see the in-app progress, so the ViewModel switches to notifications. var isLifecycleBackgrounded by remember { mutableStateOf(false) } - val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current - val view = androidx.compose.ui.platform.LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val view = LocalView.current var isWindowFocused by remember { mutableStateOf(view.hasWindowFocus()) } DisposableEffect(view) { - val listener = android.view.ViewTreeObserver.OnWindowFocusChangeListener { hasFocus -> + val listener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus -> isWindowFocused = hasFocus } view.viewTreeObserver.addOnWindowFocusChangeListener(listener) @@ -63,18 +100,11 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val isBackgrounded = isLifecycleBackgrounded || !isWindowFocused - val isBackgroundedState = androidx.compose.runtime.rememberUpdatedState(isBackgrounded) - - val permissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.RequestPermission() - ) {} - DisposableEffect(lifecycleOwner) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_PAUSE || event == androidx.lifecycle.Lifecycle.Event.ON_STOP) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { isLifecycleBackgrounded = true - } else if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME || event == androidx.lifecycle.Lifecycle.Event.ON_START) { + } else if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) { isLifecycleBackgrounded = false } } @@ -84,269 +114,105 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val notificationManager = remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager } - - val pendingIntent = remember(context) { - val intent = android.content.Intent(context, com.google.chrome.recovery.MainActivity::class.java).apply { - flags = android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP - } - android.app.PendingIntent.getActivity( - context, 0, intent, android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE - ) - } - - val buildNotification: (Float, String, String, String) -> android.app.Notification = remember(context, pendingIntent) { - { p, title, text, chipText -> - if (android.os.Build.VERSION.SDK_INT >= 36) { - val nativeBuilder = android.app.Notification.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle(title) - .setContentText(text) - .setColor(android.graphics.Color.parseColor("#4285F4")) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(android.app.Notification.CATEGORY_PROGRESS) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setProgress(100, (p * 100).toInt(), false) - - try { - nativeBuilder.setShortCriticalText(chipText) - val progressStyle = android.app.Notification.ProgressStyle() - progressStyle.setProgress((p * 100).toInt()) - nativeBuilder.setStyle(progressStyle) - - val extras = android.os.Bundle() - extras.putBoolean("android.requestPromotedOngoing", true) - nativeBuilder.addExtras(extras) - } catch (e: Exception) { - android.util.Log.e("FlashScreen", "Error setting Live Update styles", e) - } - - val built = nativeBuilder.build() - built.flags = built.flags or 262144 - built - } else { - androidx.core.app.NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle(title) - .setContentText(text) - .setColor(android.graphics.Color.parseColor("#4285F4")) - .setProgress(1000, (p * 1000).toInt(), false) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(androidx.core.app.NotificationCompat.CATEGORY_PROGRESS) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build() - } - } - } - - val funWords = remember { listOf("Flashing...", "Fluxabilating...", "Gone fishing...", "Crunching bytes...", "Reticulating splines...", "Writing magic...", "Doing the heavy lifting...") } - val shortChipTexts = remember { listOf("Working", "Writing", "Wait...", "Almost", "Hold on", "Steady", "Busy") } - - LaunchedEffect(Unit) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { - permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) - } - } - - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - val channel = android.app.NotificationChannel( - "flash_progress", - "Flash Progress", - android.app.NotificationManager.IMPORTANCE_DEFAULT - ) - notificationManager.createNotificationChannel(channel) - } - - // Start Foreground Service gracefully once at the beginning to claim foreground priority - // This prevents ForegroundServiceStartNotAllowedException if the user minimizes during eraseFirst - val initialNotif = buildNotification(0f, "Preparing to flash", "Initializing...", "Started") - com.google.chrome.recovery.usb.KeepAliveService.currentNotification = initialNotif - - val serviceIntent = android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java) - try { - if (android.os.Build.VERSION.SDK_INT >= 26) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - } catch (e: Exception) { - android.util.Log.e("FlashScreen", "Could not start KeepAliveService", e) - } - } - + val isBackgrounded = isLifecycleBackgrounded || !isWindowFocused LaunchedEffect(isBackgrounded) { - if (!isBackgrounded) { - notificationManager.cancel(1001) - } else if ((isFlashing || isErasing) && !isFinished && !hasError) { - // Post an immediate notification when focus is lost - val p = progress - val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() - val currentFunWord = if (isErasing) "Erasing media..." else funWords[wordIndex] - val chipText = if (isErasing) "Erasing" else shortChipTexts[wordIndex] - - val notif = buildNotification(p, currentFunWord, String.format(java.util.Locale.US, "%.1f%% complete", p * 100), chipText) - - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - } + viewModel.onBackgroundedChanged(isBackgrounded) } - LaunchedEffect(isErasing) { - if (isErasing) { - currentStep = "Erasing media..." - for (i in 1..100) { - progress = i / 100f - val notif = buildNotification(progress, "Erasing media...", String.format(java.util.Locale.US, "%.1f%% complete", progress * 100), "Erasing") - com.google.chrome.recovery.usb.KeepAliveService.currentNotification = notif - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - delay(30) - } - if (isCancelledErase) { - currentStep = "Success! Your USB has been reset." - progress = 1f - isFinished = true - hasError = false - } else { - isErasing = false - isFlashing = true - } + // One distinct haptic at the moment the flow ends: Confirm for success, + // Reject for failure. performHapticFeedback respects the system's + // touch-feedback setting, so users who disabled haptics feel nothing. + val haptics = LocalHapticFeedback.current + LaunchedEffect(uiState.isFinished) { + if (uiState.isFinished) { + haptics.performHapticFeedback( + if (uiState.hasError) HapticFeedbackType.Reject else HapticFeedbackType.Confirm + ) } } - LaunchedEffect(isFlashing) { - if (!isFlashing) return@LaunchedEffect - - val errorMsg = flasher.flashImageToUsb( - device = device, - url = url, - onStep = { step -> currentStep = step }, - onProgress = { p -> - progress = p - val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() - val currentFunWord = funWords[wordIndex] - val chipText = shortChipTexts[wordIndex] - - val text = String.format(java.util.Locale.US, "%.1f%% complete", p * 100) - val notif = buildNotification(p, currentFunWord, text, chipText) - - com.google.chrome.recovery.usb.KeepAliveService.currentNotification = notif - - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - } - ) - - if (errorMsg == "Cancelled") { - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) - } else if (errorMsg == null) { - currentStep = "Success! Your recovery media is ready." - progress = 1f - isFinished = true - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) - if (isBackgroundedState.value) { - val builder = androidx.core.app.NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentTitle("Recovery Media Ready") - .setContentText("Success! Your recovery media is ready.") - .setOngoing(false) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, builder.build()) - } - } - } else { - currentStep = "Error: $errorMsg" - hasError = true - isFinished = true - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) - if (isBackgroundedState.value) { - val builder = androidx.core.app.NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Error creating media") - .setContentText(errorMsg) - .setOngoing(false) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, builder.build()) - } - } - } - } + // Animated with the spec the wavy indicator is designed around, so discrete + // progress callbacks (every ~500ms from the flasher) glide instead of stepping. + val animatedProgress by animateFloatAsState( + targetValue = uiState.progress, + animationSpec = WavyProgressIndicatorDefaults.ProgressAnimationSpec, + label = "flashProgress" + ) Column( - modifier = Modifier.fillMaxSize().padding(24.dp), + modifier = Modifier.wizardContentWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - if (isFinished) { - if (!hasError) { - androidx.compose.material3.Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = "Success", - tint = androidx.compose.material3.MaterialTheme.colorScheme.primary, - modifier = androidx.compose.ui.Modifier.size(64.dp).padding(bottom = 16.dp) + if (uiState.isFinished) { + if (!uiState.hasError) { + MorphingSuccessBadge( + contentDescription = stringResource(R.string.flash_success_icon), + modifier = Modifier.padding(bottom = 24.dp) ) } else { - androidx.compose.material3.Icon( - imageVector = Icons.Filled.Warning, - contentDescription = "Error", - tint = androidx.compose.material3.MaterialTheme.colorScheme.error, - modifier = androidx.compose.ui.Modifier.size(64.dp).padding(bottom = 16.dp) + ErrorBadge( + contentDescription = stringResource(R.string.flash_error_icon), + modifier = Modifier.padding(bottom = 24.dp) ) } } - androidx.compose.material3.Text( - text = currentStep, - style = androidx.compose.material3.MaterialTheme.typography.titleLarge, - modifier = androidx.compose.ui.Modifier.padding(bottom = 32.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + Text( + text = uiState.stepText, + style = if (uiState.isFinished) MaterialTheme.typography.titleLargeEmphasized else MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 32.dp), + textAlign = TextAlign.Center ) - - if (!isFinished && !isErasing) { - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxWidth().height(8.dp) + + if (!uiState.isFinished && !uiState.isErasing) { + LinearWavyProgressIndicator( + progress = { animatedProgress }, + // The wave settles flat as the write completes, per the Expressive spec. + amplitude = { p -> if (p >= 1f) 0f else 1f }, + modifier = Modifier.fillMaxWidth() ) Text( - text = "${(progress * 100).toInt()}%", + text = stringResource(R.string.flash_progress_percent, (uiState.progress * 100).toInt()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) Spacer(modifier = Modifier.height(24.dp)) - OutlinedButton(onClick = { - flasher.cancel() - currentStep = "Erasing media..." - progress = 0f - isCancelledErase = true - isErasing = true - }) { - Text("Cancel and reset USB Stick") + if (uiState.isVerifying) { + // Skipping verification is allowed and is not a failure; cancelling + // and erasing mid-verification would destroy a completed write. + OutlinedButton(onClick = { viewModel.skipVerification() }) { + Text(stringResource(R.string.flash_skip_verification)) + } + } else { + OutlinedButton(onClick = { viewModel.cancelFlashAndReset() }) { + Text(stringResource(R.string.flash_cancel_and_reset)) + } } - } else if (isErasing && !isFinished) { - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxWidth().height(8.dp) + } else if (uiState.isErasing && !uiState.isFinished) { + LinearWavyProgressIndicator( + progress = { animatedProgress }, + amplitude = { p -> if (p >= 1f) 0f else 1f }, + modifier = Modifier.fillMaxWidth() ) Text( - text = "${(progress * 100).toInt()}%", + text = stringResource(R.string.flash_progress_percent, (uiState.progress * 100).toInt()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) } else { - Button(onClick = onFinish) { - Text("Back to Home") + if (uiState.canRetry) { + Button(onClick = { viewModel.retryFlash() }) { + Text(stringResource(R.string.flash_retry)) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onFinish) { + Text(stringResource(R.string.action_back_to_home)) + } + } else { + Button(onClick = onFinish) { + Text(stringResource(R.string.action_back_to_home)) + } } } } diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashViewModel.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashViewModel.kt new file mode 100644 index 0000000..639b4dd --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashViewModel.kt @@ -0,0 +1,289 @@ +package com.google.chrome.recovery.ui.screens + +import android.app.Application +import android.content.Context +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.chrome.recovery.R +import com.google.chrome.recovery.usb.FlashNotificationController +import com.google.chrome.recovery.usb.FlashNotificationController.Phase +import com.google.chrome.recovery.usb.UsbFlasher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Snapshot of everything [FlashScreen] needs to render. + * + * @param stepText The headline status line (step description, success message, or error). + * @param progress Progress of the current phase (write or verification), 0f..1f. + * @param isErasing True while the pre-flash (or cancel-triggered) erase pass is running. + * @param isVerifying True while the post-write read-back verification is running. + * @param isFinished True once the flow reached a terminal state (success or error). + * @param hasError True if the terminal state was an error. + * @param canRetry True when the terminal error is a verification failure — the write + * itself worked, so offering a full re-flash is the actionable recovery. + */ +data class FlashUiState( + val stepText: String = "", + val progress: Float = 0f, + val isErasing: Boolean = false, + val isVerifying: Boolean = false, + val isFinished: Boolean = false, + val hasError: Boolean = false, + val canRetry: Boolean = false, +) + +/** + * ViewModel owning the flash execution state machine: + * + * (erasing) -> flashing -> verifying -> success / error + * | | + * | +-- skip -> success (unverified; skip != fail) + * +-- cancel -> erasing (reset) -> success + * + * It drives [UsbFlasher] on a background dispatcher, holds the UI state across + * configuration changes, and delegates every notification / Foreground Service + * concern to [FlashNotificationController]. [FlashScreen] is left as a pure + * renderer of [FlashUiState]. + * + * Verification is default-on: official images are checked twice (the download + * against the manifest checksum, the drive against the written bytes); local + * images get write verification only, since there is no authority to check + * authenticity against. + * + * Scoped to the flash destination's NavBackStackEntry, so the running flash + * survives rotation/resize — and [onCleared] cancels the flasher, so leaving + * the screen no longer orphans a write loop that keeps running blind. + * + * @param device The physical USB device the user has authorized writing to. + * @param url The local or remote URL pointing to the recovery image .zip file. + * @param expectedSha1 Manifest SHA-1 of the download, when flashing an official + * image; null for local files. + * @param eraseFirst If true, a 3-second erase simulation runs before flashing. + */ +class FlashViewModel( + application: Application, + private val device: UsbDevice, + private val url: String, + private val expectedSha1: String?, + private val expectedImageSize: Long?, + private val eraseFirst: Boolean, +) : AndroidViewModel(application) { + + private val usbManager = application.getSystemService(Context.USB_SERVICE) as UsbManager + private val flasher = UsbFlasher(usbManager, application) + private val notifications = FlashNotificationController(application) + + private val _uiState = MutableStateFlow( + FlashUiState(stepText = application.getString(R.string.flash_step_starting), isErasing = eraseFirst) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var started = false + private var isCancelledReset = false + + /** + * Tracks whether the app is currently backgrounded (reported by the screen), + * so terminal notifications are only posted when the user isn't looking. + * Volatile because the flash pipeline reads it from a background dispatcher. + */ + @Volatile + private var isBackgrounded = false + + /** + * Kicks off the flow. Idempotent: recomposition, rotation, or returning to the + * screen must not restart a running flash. + */ + fun start() { + if (started) return + started = true + + notifications.createChannel() + // Claim foreground priority once at the beginning. This prevents + // ForegroundServiceStartNotAllowedException if the user minimizes during eraseFirst. + notifications.startKeepAlive() + + if (eraseFirst) { + eraseThenFlash() + } else { + flash() + } + } + + /** + * Cancels the in-flight write, then really erases the drive's partition + * structures so the user ends on an honestly "reset" USB stick rather than + * a half-written image behind a fake success message. The erase itself + * starts only after the flasher reports Cancelled — the write loop must + * release the USB interface before anyone else can claim it. + */ + fun cancelFlashAndReset() { + flasher.cancel() + isCancelledReset = true + _uiState.update { it.copy(stepText = string(R.string.flash_step_erasing), progress = 0f, isErasing = true, isVerifying = false) } + } + + /** Ends the verification pass early. Skipping is a success, not a failure. */ + fun skipVerification() { + flasher.skipVerification() + } + + /** + * Full re-flash after a verification failure: the drive contents can't be + * trusted, so the only honest recovery is writing (and verifying) again. + */ + fun retryFlash() { + _uiState.update { + FlashUiState(stepText = string(R.string.flash_step_starting)) + } + notifications.startKeepAlive() + flash() + } + + /** + * Called by the screen whenever the app's foreground-ness changes (lifecycle + * state or window focus). Foregrounded: the in-app UI shows progress, so the + * notification is dismissed. Backgrounded mid-run: post one immediately so the + * status chip appears without waiting for the next progress tick. + */ + fun onBackgroundedChanged(backgrounded: Boolean) { + isBackgrounded = backgrounded + val state = _uiState.value + if (!backgrounded) { + notifications.dismissProgress() + } else if (!state.isFinished && !state.hasError) { + notifications.postProgress(state.progress, state.toPhase()) + } + } + + /** + * The screen was popped from the back stack. Stop the flasher cooperatively so + * the write loop doesn't keep running against a drive nobody is watching; the + * flasher's finally block releases the USB interface. + */ + override fun onCleared() { + flasher.cancel() + } + + private fun FlashUiState.toPhase(): Phase = when { + isErasing -> Phase.ERASING + isVerifying -> Phase.VERIFYING + else -> Phase.FLASHING + } + + /** + * Real erase (zeroing the drive's partition structures) followed by the + * flash. Replaces the previous 3-second simulation that wrote nothing. + */ + private fun eraseThenFlash() { + viewModelScope.launch { + _uiState.update { it.copy(stepText = string(R.string.flash_step_erasing), isErasing = true) } + val result = flasher.eraseDevice(device) { p -> + _uiState.update { it.copy(progress = p) } + notifications.postProgress(p, Phase.ERASING) + } + when (result) { + is UsbFlasher.EraseResult.Error -> finishWithError(result.message, canRetry = false) + is UsbFlasher.EraseResult.Done -> { + _uiState.update { it.copy(isErasing = false, progress = 0f) } + flash() + } + } + } + } + + /** + * The reset path after a cancelled write: really zero the partition + * structures so nothing tries to mount the half-written image, then land + * on the reset summary. + */ + private fun eraseForReset() { + viewModelScope.launch { + val result = flasher.eraseDevice(device) { p -> + _uiState.update { it.copy(progress = p) } + notifications.postProgress(p, Phase.ERASING) + } + notifications.stopKeepAlive() + when (result) { + is UsbFlasher.EraseResult.Error -> finishWithError(result.message, canRetry = false) + is UsbFlasher.EraseResult.Done -> _uiState.update { + it.copy(stepText = string(R.string.flash_reset_success), progress = 1f, isFinished = true, hasError = false) + } + } + } + } + + private fun string(resId: Int, vararg args: Any): String = + getApplication().getString(resId, *args) + + private fun flash() { + viewModelScope.launch { + val result = flasher.flashImageToUsb( + device = device, + url = url, + expectedZipSha1 = expectedSha1, + expectedImageSize = expectedImageSize, + onStep = { step -> _uiState.update { it.copy(stepText = step) } }, + onProgress = { p -> + _uiState.update { it.copy(progress = p, isVerifying = false) } + notifications.postProgress(p, Phase.FLASHING) + }, + onVerifyProgress = { p -> + _uiState.update { it.copy(progress = p, isVerifying = true) } + notifications.postProgress(p, Phase.VERIFYING) + } + ) + + when (result) { + is UsbFlasher.Result.Cancelled -> { + if (isCancelledReset) { + // The write loop has released the USB interface; now the + // reset-erase can claim it. + isCancelledReset = false + eraseForReset() + } else { + // Cancelled because the screen was left (onCleared). + notifications.stopKeepAlive() + } + } + is UsbFlasher.Result.Success -> { + val message = when (result.verification) { + UsbFlasher.Verification.AUTHENTIC -> string(R.string.flash_success_verified) + UsbFlasher.Verification.WRITE_VERIFIED -> string(R.string.flash_success_write_verified) + UsbFlasher.Verification.SKIPPED -> string(R.string.flash_success) + } + _uiState.update { + it.copy(stepText = message, progress = 1f, isVerifying = false, isFinished = true) + } + notifications.stopKeepAlive() + if (isBackgrounded) { + notifications.postCompletion(string(R.string.notif_success_title), message, isError = false) + } + } + is UsbFlasher.Result.WriteError -> finishWithError(result.message, canRetry = false) + is UsbFlasher.Result.VerificationError -> finishWithError(result.message, canRetry = true) + } + } + } + + private fun finishWithError(message: String, canRetry: Boolean) { + _uiState.update { + it.copy( + stepText = string(R.string.flash_error, message), + isVerifying = false, + isFinished = true, + hasError = true, + canRetry = canRetry + ) + } + notifications.stopKeepAlive() + if (isBackgrounded) { + notifications.postCompletion(string(R.string.notif_error_title), message, isError = true) + } + } +} diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/IdentifyScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/IdentifyScreen.kt index eb8a6d6..4802e7f 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/IdentifyScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/IdentifyScreen.kt @@ -4,20 +4,28 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.google.chrome.recovery.R import com.google.chrome.recovery.data.RecoveryImage import com.google.chrome.recovery.data.RecoveryRepository +import com.google.chrome.recovery.ui.wizardContentWidth @OptIn(ExperimentalMaterial3Api::class) /** @@ -38,11 +46,12 @@ fun IdentifyScreen( onSelectFromList: () -> Unit, onSelectLocalImage: () -> Unit ) { - val repository = remember { RecoveryRepository() } + val haptics = LocalHapticFeedback.current + val repository = RecoveryRepository.instance var images by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } - var typedModel by remember { mutableStateOf("") } + var typedModel by rememberSaveable { mutableStateOf("") } var showError by remember { mutableStateOf(false) } var showHowToDialog by remember { mutableStateOf(false) } @@ -54,17 +63,17 @@ fun IdentifyScreen( if (showHowToDialog) { AlertDialog( onDismissRequest = { showHowToDialog = false }, - title = { Text("How to find your model number") }, + title = { Text(stringResource(R.string.identify_how_to_title)) }, text = { Column { - Text("The model number is located at the bottom of the recovery screen on your Book.") + Text(stringResource(R.string.identify_how_to_body)) Spacer(modifier = Modifier.height(16.dp)) - Text("Example: \nPEPPY C6A-V7C-A5Q", fontWeight = FontWeight.Bold) + Text(stringResource(R.string.identify_how_to_example), fontWeight = FontWeight.Bold) } }, confirmButton = { TextButton(onClick = { showHowToDialog = false }) { - Text("OK") + Text(stringResource(R.string.action_ok)) } } ) @@ -72,15 +81,15 @@ fun IdentifyScreen( Column( modifier = Modifier - .fillMaxSize() + .wizardContentWidth() .verticalScroll(rememberScrollState()) .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text("Identify your Book", style = MaterialTheme.typography.headlineMedium) + Text(stringResource(R.string.identify_title), style = MaterialTheme.typography.headlineMedium) Spacer(modifier = Modifier.height(8.dp)) Text( - "Enter the model number of the Book you want to recover.", + stringResource(R.string.identify_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -89,7 +98,7 @@ fun IdentifyScreen( Image( painter = painterResource(id = R.drawable.insert), - contentDescription = "Identify your Book", + contentDescription = stringResource(R.string.identify_title), modifier = Modifier.height(140.dp), contentScale = ContentScale.Fit ) @@ -116,22 +125,35 @@ fun IdentifyScreen( } } else null + // Continue is shared by the button and the field's Done/Enter action, + // so a hardware keyboard can drive this step without touching the screen. + val submitModel = { + if (match?.name != null) { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onNext(match.name) + } else { + showError = true + } + } + OutlinedTextField( value = typedModel, onValueChange = { typedModel = it showError = false }, - label = { Text("Type model number") }, + label = { Text(stringResource(R.string.identify_type_model)) }, isError = showError, - supportingText = if (showError) { { Text("Model not found. Please check and try again.") } } else null, + supportingText = if (showError) { { Text(stringResource(R.string.identify_model_not_found)) } } else null, modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (typedModel.isNotBlank()) submitModel() }), trailingIcon = { if (match != null) { Icon( imageVector = Icons.Filled.CheckCircle, - contentDescription = "Valid Model", + contentDescription = stringResource(R.string.identify_valid_model), tint = MaterialTheme.colorScheme.primary ) } @@ -141,7 +163,7 @@ fun IdentifyScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "How to find this?", + text = stringResource(R.string.identify_how_to_find), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier @@ -152,24 +174,18 @@ fun IdentifyScreen( Spacer(modifier = Modifier.height(24.dp)) Button( - onClick = { - if (match?.name != null) { - onNext(match.name) - } else { - showError = true - } - }, + onClick = submitModel, modifier = Modifier.fillMaxWidth(), enabled = !isLoading && typedModel.isNotBlank() ) { - Text("Continue") + Text(stringResource(R.string.action_continue)) } } } Spacer(modifier = Modifier.height(24.dp)) - Text("OR", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) + Text(stringResource(R.string.identify_or), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) Spacer(modifier = Modifier.height(24.dp)) @@ -177,7 +193,7 @@ fun IdentifyScreen( onClick = onSelectFromList, modifier = Modifier.fillMaxWidth() ) { - Text("Select a model from a list") + Text(stringResource(R.string.identify_select_from_list)) } Spacer(modifier = Modifier.height(16.dp)) @@ -186,7 +202,7 @@ fun IdentifyScreen( onClick = onSelectLocalImage, modifier = Modifier.fillMaxWidth() ) { - Text("Use local image") + Text(stringResource(R.string.identify_use_local_image)) } } } diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectChannelScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectChannelScreen.kt index 0cfcb2f..fc8d49f 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectChannelScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectChannelScreen.kt @@ -4,9 +4,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.google.chrome.recovery.R import com.google.chrome.recovery.data.RecoveryImage import com.google.chrome.recovery.data.RecoveryRepository +import com.google.chrome.recovery.ui.wizardContentWidth /** * The Channel Selection Screen (Wizard Step 3). @@ -22,9 +27,11 @@ import com.google.chrome.recovery.data.RecoveryRepository * * Selecting a channel passes the final ZIP download URL to the next screen. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun SelectChannelScreen(modelName: String, onNext: (String) -> Unit) { - val repository = remember { RecoveryRepository() } +fun SelectChannelScreen(modelName: String, onNext: (RecoveryImage) -> Unit) { + val haptics = LocalHapticFeedback.current + val repository = RecoveryRepository.instance var images by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } @@ -37,22 +44,27 @@ fun SelectChannelScreen(modelName: String, onNext: (String) -> Unit) { images.filter { it.name == modelName }.sortedBy { it.channel } } - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Select a channel", style = MaterialTheme.typography.headlineMedium) + Column(modifier = Modifier.wizardContentWidth().padding(24.dp)) { + Text(stringResource(R.string.select_channel_title), style = MaterialTheme.typography.headlineMedium) Spacer(modifier = Modifier.height(8.dp)) - Text("Choose the recovery channel for $modelName.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.select_channel_body, modelName), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(modifier = Modifier.height(32.dp)) if (isLoading) { - CircularProgressIndicator() + LoadingIndicator() } else { if (availableImages.isEmpty()) { - Text("No channels found for this model.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.select_channel_none), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) } else { availableImages.forEach { image -> - val channelLabel = image.channel ?: "STABLE" + val channelLabel = image.channel ?: stringResource(R.string.select_channel_default_label) ElevatedButton( - onClick = { image.url?.let { onNext(it) } }, + onClick = { + if (image.url != null) { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onNext(image) + } + }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) ) { Text(channelLabel) diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectDriveScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectDriveScreen.kt index 63478f8..23762e2 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectDriveScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectDriveScreen.kt @@ -1,8 +1,13 @@ package com.google.chrome.recovery.ui.screens +import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager +import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -13,18 +18,48 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.google.chrome.recovery.R +import com.google.chrome.recovery.ui.wizardContentWidth @Composable fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, onEraseFirst: ((UsbDevice) -> Unit)? = null) { val context = LocalContext.current + val haptics = LocalHapticFeedback.current val usbManager = remember { context.getSystemService(Context.USB_SERVICE) as UsbManager } var selectedDevice by remember { mutableStateOf(null) } - - // In a real app we would listen for attach/detach broadcasts - val deviceList = remember { usbManager.deviceList.values.toList() } + + // Keep the device list live: plugging in a drive while this screen is open + // (the natural order of operations) should make it appear without leaving + // and re-entering. Detach also clears a now-stale selection. + var deviceList by remember { mutableStateOf(usbManager.deviceList.values.toList()) } + DisposableEffect(context) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + deviceList = usbManager.deviceList.values.toList() + if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED && + selectedDevice != null && selectedDevice !in deviceList + ) { + selectedDevice = null + } + } + } + val filter = IntentFilter().apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + // NOT_EXPORTED still receives system broadcasts; it only blocks other apps. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + onDispose { + context.unregisterReceiver(receiver) + } + } var showErasePrompt by remember { mutableStateOf(false) } @@ -33,11 +68,11 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, var permissionError by remember { mutableStateOf(null) } DisposableEffect(context) { - val receiver = object : android.content.BroadcastReceiver() { - override fun onReceive(context: Context, intent: android.content.Intent) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { if (ACTION_USB_PERMISSION == intent.action) { synchronized(this) { - val device: UsbDevice? = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + val device: UsbDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) } else { @Suppress("DEPRECATION") @@ -49,20 +84,19 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, pendingAction = null } } else { - android.util.Log.d("USB", "permission denied for device $device") - permissionError = "Permission denied for USB device." + permissionError = context.getString(R.string.permission_denied_usb) pendingAction = null } } } } } - val filter = android.content.IntentFilter(ACTION_USB_PERMISSION) - androidx.core.content.ContextCompat.registerReceiver( - context, - receiver, - filter, - androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED + val filter = IntentFilter(ACTION_USB_PERMISSION) + ContextCompat.registerReceiver( + context, + receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED ) onDispose { context.unregisterReceiver(receiver) @@ -75,29 +109,29 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } else { pendingAction = action permissionError = null - val permissionIntent = android.app.PendingIntent.getBroadcast( - context, - 0, - android.content.Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) - android.app.PendingIntent.FLAG_MUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT - else - android.app.PendingIntent.FLAG_UPDATE_CURRENT + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else + PendingIntent.FLAG_UPDATE_CURRENT ) usbManager.requestPermission(device, permissionIntent) } } - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Insert your USB flash drive or SD card", style = MaterialTheme.typography.titleLarge) + Column(modifier = Modifier.wizardContentWidth().padding(16.dp)) { + Text(stringResource(R.string.select_drive_title), style = MaterialTheme.typography.titleLarge) Spacer(modifier = Modifier.height(16.dp)) - Text("Select the media you'd like to use.", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.select_drive_body), style = MaterialTheme.typography.bodyLarge) Spacer(modifier = Modifier.height(24.dp)) if (deviceList.isEmpty()) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text("Please insert a USB drive.", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.select_drive_empty), color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { LazyColumn(modifier = Modifier.weight(1f)) { @@ -106,7 +140,12 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) - .clickable { selectedDevice = device }, + .clickable { + if (selectedDevice != device) { + haptics.performHapticFeedback(HapticFeedbackType.ToggleOn) + } + selectedDevice = device + }, colors = CardDefaults.cardColors( containerColor = if (selectedDevice == device) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant ) @@ -115,8 +154,8 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, Icon(Icons.Filled.Build, contentDescription = null, modifier = Modifier.size(32.dp)) Spacer(modifier = Modifier.width(16.dp)) Column { - Text(device.productName ?: "Unknown USB Device", fontWeight = FontWeight.Medium) - Text("Manufacturer: ${device.manufacturerName ?: "Unknown"}", style = MaterialTheme.typography.bodySmall) + Text(device.productName ?: stringResource(R.string.select_drive_unknown_device), fontWeight = FontWeight.Medium) + Text(stringResource(R.string.select_drive_manufacturer, device.manufacturerName ?: stringResource(R.string.select_drive_unknown_manufacturer)), style = MaterialTheme.typography.bodySmall) } } } @@ -137,7 +176,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, }, enabled = selectedDevice != null ) { - Text("Continue") + Text(stringResource(R.string.action_continue)) } } } @@ -145,8 +184,8 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, if (showErasePrompt) { AlertDialog( onDismissRequest = { showErasePrompt = false }, - title = { Text("Erase drive before proceeding?") }, - text = { Text("Do you want to format and erase the drive before proceeding with the recovery image? This can help resolve issues with previously used media.") }, + title = { Text(stringResource(R.string.erase_prompt_title)) }, + text = { Text(stringResource(R.string.erase_prompt_body)) }, confirmButton = { Button(onClick = { showErasePrompt = false @@ -156,7 +195,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } } }) { - Text("Yes, erase first") + Text(stringResource(R.string.erase_prompt_confirm)) } }, dismissButton = { @@ -164,7 +203,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, showErasePrompt = false selectedDevice?.let { dev -> handleDeviceSelection(dev) { onNext(dev) } } }) { - Text("Skip and proceed") + Text(stringResource(R.string.erase_prompt_skip)) } } ) @@ -173,11 +212,11 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, if (permissionError != null) { AlertDialog( onDismissRequest = { permissionError = null }, - title = { Text("Permission Required") }, + title = { Text(stringResource(R.string.permission_required_title)) }, text = { Text(permissionError ?: "") }, confirmButton = { Button(onClick = { permissionError = null }) { - Text("OK") + Text(stringResource(R.string.action_ok)) } } ) diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectModelScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectModelScreen.kt index ef11563..6a5268b 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectModelScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectModelScreen.kt @@ -4,50 +4,272 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import androidx.window.core.layout.WindowSizeClass +import com.google.chrome.recovery.R import com.google.chrome.recovery.data.RecoveryImage import com.google.chrome.recovery.data.RecoveryRepository +import com.google.chrome.recovery.ui.wizardContentWidth +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) /** * The Model Selection Screen (Wizard Step 2b). * * This screen is presented if the user opts to manually select their Chromebook model - * instead of entering their hardware ID (hwid). It fetches the full list of recovery - * images, groups them by manufacturer, and populates cascading dropdown menus. - * - * Flow: - * 1. User selects a Manufacturer (e.g., "Acer", "Google"). - * 2. User selects a specific Model belonging to that manufacturer. - * 3. Navigates to [SelectChannelScreen] to pick the release channel. + * instead of entering their hardware ID (hwid). It fetches the full list of recovery + * images and adapts its layout to the window width: + * + * - **Compact/medium widths** (phones, small windows): the original flow — cascading + * manufacturer and product dropdowns, then a separate channel-selection step. + * - **Expanded widths** (tablets, desktop windows, unfolded foldables): a + * list–detail pane layout. A searchable model list fills the left pane; choosing a + * model shows its details and release channels on the right, collapsing the model + * and channel steps into one screen. This follows the list-detail canonical layout + * from the official adaptive guidance. + * + * @param onNext Compact flow: called with the chosen model name; navigates to the + * channel-selection step. + * @param onImageSelected Expanded flow: called with the chosen image's download URL + * (channel already picked in the detail pane); navigates straight to drive selection. */ @Composable -fun SelectModelScreen(onNext: (String) -> Unit) { - val repository = remember { RecoveryRepository() } +fun SelectModelScreen(onNext: (String) -> Unit, onImageSelected: (RecoveryImage) -> Unit) { + val repository = RecoveryRepository.instance var images by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } - var selectedManufacturer by remember { mutableStateOf(null) } - var selectedModelName by remember { mutableStateOf(null) } - var mfrExpanded by remember { mutableStateOf(false) } - var modelExpanded by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { images = repository.fetchRecoveryImages() isLoading = false } - var mfrSearchText by remember { mutableStateOf("") } - var modelSearchText by remember { mutableStateOf("") } + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) { + ModelListDetail(images, isLoading, onImageSelected) + } else { + ModelDropdowns(images, isLoading, onNext) + } +} + +/** + * Expanded-width layout: searchable model list on the left, model details and + * channel choice on the right. The scaffold handles pane visibility itself, so + * if the window is resized below the two-pane threshold mid-selection it + * degrades to single-pane navigation with back handling intact. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun ModelListDetail( + images: List, + isLoading: Boolean, + onImageSelected: (RecoveryImage) -> Unit +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() + + NavigableListDetailPaneScaffold( + navigator = navigator, + listPane = { + AnimatedPane { + ModelListPane( + images = images, + isLoading = isLoading, + selectedModel = navigator.currentDestination?.contentKey, + onModelClick = { modelName -> + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, modelName) + } + } + ) + } + }, + detailPane = { + AnimatedPane { + ModelDetailPane( + modelName = navigator.currentDestination?.contentKey, + images = images, + onImageSelected = onImageSelected + ) + } + } + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ModelListPane( + images: List, + isLoading: Boolean, + selectedModel: String?, + onModelClick: (String) -> Unit +) { + var searchText by rememberSaveable { mutableStateOf("") } + + // One row per distinct model name; manufacturer as supporting text. + val models = remember(images) { + images.mapNotNull { image -> + image.name?.let { name -> name to (image.manufacturer ?: "") } + }.distinct().sortedBy { it.first } + } + val filteredModels = remember(models, searchText) { + if (searchText.isBlank()) models + else models.filter { (name, manufacturer) -> + name.contains(searchText, ignoreCase = true) || + manufacturer.contains(searchText, ignoreCase = true) + } + } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text(stringResource(R.string.identify_title), style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(16.dp)) + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + label = { Text(stringResource(R.string.select_model_search)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + + if (isLoading) { + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items(filteredModels, key = { it.first }) { (name, manufacturer) -> + ListItem( + supportingContent = { Text(manufacturer) }, + colors = ListItemDefaults.colors( + containerColor = if (name == selectedModel) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + } + ), + modifier = Modifier.clickable { onModelClick(name) } + ) { + Text(name) + } + } + } + } + } +} + +@Composable +private fun ModelDetailPane( + modelName: String?, + images: List, + onImageSelected: (RecoveryImage) -> Unit +) { + val haptics = LocalHapticFeedback.current + if (modelName == null) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.select_model_pick_prompt), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + + val availableImages = remember(images, modelName) { + images.filter { it.name == modelName }.sortedBy { it.channel } + } + val manufacturer = availableImages.firstOrNull()?.manufacturer + + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text(modelName, style = MaterialTheme.typography.headlineMedium) + if (manufacturer != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + manufacturer, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Text(stringResource(R.string.select_channel_title), style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + + if (availableImages.isEmpty()) { + Text( + stringResource(R.string.select_channel_none), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } else { + availableImages.forEach { image -> + val channelLabel = image.channel ?: stringResource(R.string.select_channel_default_label) + ElevatedButton( + onClick = { + if (image.url != null) { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onImageSelected(image) + } + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Text(channelLabel) + } + } + } + } +} + +/** + * Compact-width layout: the original cascading manufacturer/product dropdowns. + * + * Flow: + * 1. User selects a Manufacturer (e.g., "Acer", "Google"). + * 2. User selects a specific Model belonging to that manufacturer. + * 3. Navigates to [SelectChannelScreen] to pick the release channel. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ModelDropdowns( + images: List, + isLoading: Boolean, + onNext: (String) -> Unit +) { + val haptics = LocalHapticFeedback.current + var selectedManufacturer by rememberSaveable { mutableStateOf(null) } + var selectedModelName by rememberSaveable { mutableStateOf(null) } + var mfrExpanded by remember { mutableStateOf(false) } + var modelExpanded by remember { mutableStateOf(false) } + + var mfrSearchText by rememberSaveable { mutableStateOf("") } + var modelSearchText by rememberSaveable { mutableStateOf("") } val manufacturers = remember(images) { images.mapNotNull { it.manufacturer }.distinct().sorted() } val filteredManufacturers = remember(manufacturers, mfrSearchText) { - if (mfrSearchText.isEmpty() || selectedManufacturer == mfrSearchText) manufacturers + if (mfrSearchText.isEmpty() || selectedManufacturer == mfrSearchText) manufacturers else manufacturers.filter { it.contains(mfrSearchText, ignoreCase = true) } } @@ -62,16 +284,14 @@ fun SelectModelScreen(onNext: (String) -> Unit) { else modelNames.filter { it.contains(modelSearchText, ignoreCase = true) } } - - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Identify your Book", style = MaterialTheme.typography.titleLarge) + Column(modifier = Modifier.wizardContentWidth().padding(16.dp)) { + Text(stringResource(R.string.identify_title), style = MaterialTheme.typography.titleLarge) Spacer(modifier = Modifier.height(16.dp)) - Text("Select the manufacturer and product.", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.select_model_body), style = MaterialTheme.typography.bodyLarge) Spacer(modifier = Modifier.height(24.dp)) if (isLoading) { - CircularProgressIndicator() + LoadingIndicator() } else { // Manufacturer Dropdown ExposedDropdownMenuBox( @@ -80,25 +300,25 @@ fun SelectModelScreen(onNext: (String) -> Unit) { ) { OutlinedTextField( value = mfrSearchText, - onValueChange = { + onValueChange = { mfrSearchText = it - mfrExpanded = true + mfrExpanded = true if (selectedManufacturer != null && it != selectedManufacturer) { selectedManufacturer = null selectedModelName = null modelSearchText = "" } }, - label = { Text("Select a manufacturer") }, + label = { Text(stringResource(R.string.select_model_manufacturer_label)) }, readOnly = false, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = mfrExpanded) }, - modifier = Modifier.menuAnchor().fillMaxWidth() + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable).fillMaxWidth() ) if (filteredManufacturers.isNotEmpty()) { DropdownMenu( expanded = mfrExpanded, onDismissRequest = { mfrExpanded = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false), + properties = PopupProperties(focusable = false), modifier = Modifier.exposedDropdownSize() ) { filteredManufacturers.forEach { mfr -> @@ -126,24 +346,24 @@ fun SelectModelScreen(onNext: (String) -> Unit) { ) { OutlinedTextField( value = modelSearchText, - onValueChange = { + onValueChange = { modelSearchText = it - modelExpanded = true + modelExpanded = true if (selectedModelName != null && it != selectedModelName) { selectedModelName = null } }, - label = { Text("Select a product") }, + label = { Text(stringResource(R.string.select_model_product_label)) }, readOnly = false, enabled = selectedManufacturer != null, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modelExpanded) }, - modifier = Modifier.menuAnchor().fillMaxWidth() + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable).fillMaxWidth() ) if (filteredModelNames.isNotEmpty() && selectedManufacturer != null) { DropdownMenu( expanded = modelExpanded, onDismissRequest = { modelExpanded = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false), + properties = PopupProperties(focusable = false), modifier = Modifier.exposedDropdownSize() ) { filteredModelNames.forEach { modelName -> @@ -164,10 +384,15 @@ fun SelectModelScreen(onNext: (String) -> Unit) { Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { Button( - onClick = { selectedModelName?.let { onNext(it) } }, + onClick = { + selectedModelName?.let { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onNext(it) + } + }, enabled = selectedModelName != null ) { - Text("Continue") + Text(stringResource(R.string.action_continue)) } } } diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/StatusBadge.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/StatusBadge.kt new file mode 100644 index 0000000..7dcf273 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/StatusBadge.kt @@ -0,0 +1,98 @@ +package com.google.chrome.recovery.ui.screens + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.toPath +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph + +/** + * Terminal-state badges for the flash and erase flows. + * + * The success badge is the flow's one moment of celebration: a burst polygon + * that morphs into a circle around the checkmark, driven by the theme's + * expressive motion scheme (Material's sanctioned shape-morph pattern: + * [MaterialShapes] polygons + a graphics-shapes [Morph] + an animated float — + * the same construction LoadingIndicator uses internally). + * + * The error badge is deliberately calmer — a static soft "bun" container in + * error-container colors, no motion — so a failed flash reads as "stop and + * read this", not as an alarm. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MorphingSuccessBadge(contentDescription: String?, modifier: Modifier = Modifier) { + val morph = remember { Morph(MaterialShapes.SoftBurst, MaterialShapes.Circle) } + val progress = remember { Animatable(0f) } + // Spatial (springy) spec for the scale-in; the morph fraction itself is + // clamped because a spring overshoot past 1f has no defined shape. + val spec = MaterialTheme.motionScheme.slowSpatialSpec() + LaunchedEffect(Unit) { + progress.animateTo(1f, spec) + } + + val morphFraction = progress.value.coerceIn(0f, 1f) + val shape = remember(morphFraction) { + GenericShape { size, _ -> + val path = morph.toPath(morphFraction) + val matrix = Matrix() + matrix.scale(size.width, size.height) + path.transform(matrix) + addPath(path) + } + } + + Box( + modifier = modifier + .size(88.dp) + .scale(0.6f + 0.4f * progress.value) + .clip(shape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(40.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ErrorBadge(contentDescription: String?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(88.dp) + .clip(MaterialShapes.Bun.toShape()) + .background(MaterialTheme.colorScheme.errorContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(40.dp) + ) + } +} diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/WelcomeScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/WelcomeScreen.kt index 7ef0b83..ad672fb 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/WelcomeScreen.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/WelcomeScreen.kt @@ -3,22 +3,28 @@ package com.google.chrome.recovery.ui.screens import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.google.chrome.recovery.R +import com.google.chrome.recovery.ui.wizardContentWidth @Composable fun WelcomeScreen(onNext: () -> Unit) { + val haptics = LocalHapticFeedback.current Column( modifier = Modifier - .fillMaxSize() + .wizardContentWidth() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center @@ -30,20 +36,27 @@ fun WelcomeScreen(onNext: () -> Unit) { contentScale = ContentScale.Fit ) Text( - text = "Create recovery media for your Book", + text = stringResource(R.string.welcome_title), style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(bottom = 16.dp) ) Text( - text = "You will need an 8 GB or larger USB flash drive or SD card that you don't mind erasing.", + text = stringResource(R.string.welcome_body), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 32.dp) ) - Button(onClick = onNext) { - Text("Get started") + Button( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onNext() + }, + // Expressive button shapes: the corners morph slightly on press. + shapes = ButtonDefaults.shapes() + ) { + Text(stringResource(R.string.welcome_get_started)) } } } diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Theme.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Theme.kt index 920c937..ad4a87e 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Theme.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Theme.kt @@ -3,7 +3,8 @@ package com.google.chrome.recovery.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -35,6 +36,7 @@ private val LightColorScheme = lightColorScheme( onSurface = ChromeText ) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChromebookRecoveryTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -59,7 +61,10 @@ fun ChromebookRecoveryTheme( } } - MaterialTheme( + // MaterialExpressiveTheme keeps our color scheme but adopts the expressive + // defaults for everything left null: the springy MotionScheme (read by the + // wizard transitions and wavy indicators) and the expressive shape family. + MaterialExpressiveTheme( colorScheme = colorScheme, typography = Typography, content = content diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Type.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Type.kt index b72fc95..a90f516 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Type.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/theme/Type.kt @@ -1,24 +1,14 @@ package com.google.chrome.recovery.ui.theme import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ) -) +/** + * The app's type scale: the stock Material 3 typography, which on material3 + * 1.5.x includes the full Expressive scale (the 15 *Emphasized styles) used + * for the flash flow's terminal states. + * + * The previous hand-rolled bodyLarge/titleLarge matched the Material defaults + * except titleLarge's weight (500 vs the spec's 400); adopting the stock + * scale keeps this file the single source of truth going forward. + */ +val Typography = Typography() diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/FlashNotificationController.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/FlashNotificationController.kt new file mode 100644 index 0000000..74c3fb1 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/FlashNotificationController.kt @@ -0,0 +1,207 @@ +package com.google.chrome.recovery.usb + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.google.chrome.recovery.MainActivity +import com.google.chrome.recovery.R +import java.util.Locale + +/** + * Owns every notification and foreground-service concern of the flash flow. + * + * The flashing pipeline needs three pieces of system plumbing to survive and stay + * visible while the user is away from the app: + * 1. The "flash_progress" notification channel. + * 2. The [KeepAliveService] Foreground Service, which claims foreground priority so the + * OS doesn't kill the multi-minute write when the app is backgrounded. + * 3. The progress notification itself — on API 36 (Android 16) an "amber-alert style" + * Live Update built with `Notification.ProgressStyle()` and `setShortCriticalText` + * (projected into the status-bar chip), with a `NotificationCompat` fallback for + * older releases. + * + * Keeping all of that here means [com.google.chrome.recovery.ui.screens.FlashViewModel] + * only decides *when* to notify, never *how*. + */ +class FlashNotificationController(private val context: Context) { + + /** Which phase the progress notification narrates. */ + enum class Phase { ERASING, FLASHING, VERIFYING } + + companion object { + private const val TAG = "FlashNotifications" + private const val CHANNEL_ID = "flash_progress" + private const val NOTIFICATION_ID = 1001 + + /** + * `Notification.FLAG_PROMOTED_ONGOING` (1 shl 18). Requests promotion to a + * Live Update on API 36. The constant is not exposed through the public SDK + * stubs we compile against, so the raw value is used. + */ + private const val FLAG_PROMOTED_ONGOING = 262144 + } + + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Rotating titles shown while flashing. The status-bar chip only fits a short + // word, hence the parallel array of compact variants. + private val funWords = context.resources.getStringArray(R.array.flash_fun_words) + private val shortChipTexts = context.resources.getStringArray(R.array.flash_fun_chips) + + private val pendingIntent: PendingIntent by lazy { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** Creates the progress notification channel. Safe to call repeatedly. */ + fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notif_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Starts [KeepAliveService] with an initial notification so the app holds + * foreground priority for the whole flash, even if the user immediately + * minimizes during an erase-first pass. + */ + fun startKeepAlive() { + val initialNotif = buildProgressNotification( + 0f, + context.getString(R.string.notif_preparing_title), + context.getString(R.string.notif_preparing_text), + context.getString(R.string.notif_preparing_chip) + ) + KeepAliveService.currentNotification = initialNotif + + val serviceIntent = Intent(context, KeepAliveService::class.java) + try { + if (Build.VERSION.SDK_INT >= 26) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } catch (e: Exception) { + Log.e(TAG, "Could not start KeepAliveService", e) + } + } + + /** Stops [KeepAliveService] once the flash finishes, fails, or is cancelled. */ + fun stopKeepAlive() { + context.stopService(Intent(context, KeepAliveService::class.java)) + } + + /** + * Publishes the current progress: refreshes the notification the Foreground + * Service is holding and (if the user granted notification permission) posts it. + */ + fun postProgress(progress: Float, phase: Phase) { + val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() + // Erase and verify phases stay factual; the fun words are a flashing-only thing. + val title = when (phase) { + Phase.ERASING -> context.getString(R.string.notif_erasing_title) + Phase.VERIFYING -> context.getString(R.string.notif_verifying_title) + Phase.FLASHING -> funWords[wordIndex] + } + val chipText = when (phase) { + Phase.ERASING -> context.getString(R.string.notif_erasing_chip) + Phase.VERIFYING -> context.getString(R.string.notif_verifying_chip) + Phase.FLASHING -> shortChipTexts[wordIndex] + } + val text = String.format(Locale.US, context.getString(R.string.notif_percent_complete), progress * 100) + + val notification = buildProgressNotification(progress, title, text, chipText) + KeepAliveService.currentNotification = notification + notifyIfPermitted(notification) + } + + /** Removes the progress notification (the user is looking at the app again). */ + fun dismissProgress() { + notificationManager.cancel(NOTIFICATION_ID) + } + + /** Posts the terminal success/error notification shown when the app is backgrounded. */ + fun postCompletion(title: String, text: String, isError: Boolean) { + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.stat_sys_download_done) + .setContentTitle(title) + .setContentText(text) + .setOngoing(false) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + notifyIfPermitted(builder.build()) + } + + private fun notifyIfPermitted(notification: Notification) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + notificationManager.notify(NOTIFICATION_ID, notification) + } + } + + private fun buildProgressNotification(progress: Float, title: String, text: String, chipText: String): Notification { + return if (Build.VERSION.SDK_INT >= 36) { + val nativeBuilder = Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(title) + .setContentText(text) + .setColor(Color.parseColor("#4285F4")) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_PROGRESS) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setProgress(100, (progress * 100).toInt(), false) + + try { + nativeBuilder.setShortCriticalText(chipText) + val progressStyle = Notification.ProgressStyle() + progressStyle.setProgress((progress * 100).toInt()) + nativeBuilder.setStyle(progressStyle) + + val extras = Bundle() + extras.putBoolean("android.requestPromotedOngoing", true) + nativeBuilder.addExtras(extras) + } catch (e: Exception) { + Log.e(TAG, "Error setting Live Update styles", e) + } + + val built = nativeBuilder.build() + built.flags = built.flags or FLAG_PROMOTED_ONGOING + built + } else { + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(title) + .setContentText(text) + .setColor(Color.parseColor("#4285F4")) + .setProgress(1000, (progress * 1000).toInt(), false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + } + } +} diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt index 317aa52..9dc6c15 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt @@ -3,6 +3,8 @@ package com.google.chrome.recovery.usb import android.app.Notification import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder /** @@ -27,9 +29,9 @@ class KeepAliveService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { currentNotification?.let { try { - if (android.os.Build.VERSION.SDK_INT >= 34) { + if (Build.VERSION.SDK_INT >= 34) { // API 34+ requires specifying foregroundServiceType in startForeground - val type = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE startForeground(1001, it, type) } else { startForeground(1001, it) diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt index 0b1ca92..3e1fbbd 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt @@ -1,16 +1,26 @@ package com.google.chrome.recovery.usb import android.content.Context +import android.hardware.usb.UsbConstants import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager +import android.net.Uri +import android.provider.OpenableColumns import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import com.google.chrome.recovery.R +import com.google.chrome.recovery.usb.bot.BotDevice +import com.google.chrome.recovery.usb.verify.BoundedDigest +import com.google.chrome.recovery.usb.verify.IncrementalDigest +import java.io.FilterInputStream import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.zip.ZipInputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Handles the low-level USB writing of the recovery image. @@ -18,55 +28,126 @@ import java.util.zip.ZipInputStream * This class abstracts the Android USB Host API, handling the complexities of: * 1. Claiming the correct USB Interfaces and Endpoints for block-level transfer. * 2. Unzipping the ChromeOS recovery `.bin` payload on the fly from a `.zip` stream. - * 3. Safely executing bulk transfers of the 16GB+ images to the physical flash drive + * 3. Safely executing bulk transfers of the 16GB+ images to the physical flash drive * in chunks to prevent OutOfMemory errors on constrained mobile devices. - * + * 4. Verifying the result: the downloaded `.zip` is hashed in flight and compared + * against the manifest checksum (authenticity), and the written sectors are read + * back over the same bulk endpoints and compared against the digest of the data + * that was written (write verification). + * * Note: Since standard Android devices lack root block access (`/dev/block/sda`), this * implementation relies on the USB Mass Storage Class protocol to communicate via SCSI commands. */ class UsbFlasher(private val usbManager: UsbManager, private val context: Context) { + companion object { + private const val TAG = "UsbFlasher" + private const val SECTOR_SIZE = 512 + + /** Read-back chunk: 128 sectors = 64 KB, the same buffer discipline as the write path. */ + private const val VERIFY_CHUNK_SECTORS = 128 + + /** Erase: zero the first 16 MiB (MBR/GPT + early filesystem metadata). */ + private const val HEAD_WIPE_BYTES = 16L * 1024 * 1024 + + /** Erase: zero the last 1 MiB (backup GPT), when capacity is known. */ + private const val TAIL_WIPE_BYTES = 1L * 1024 * 1024 + } + + /** Terminal outcome of a flash attempt. */ + sealed interface Result { + /** The image was written and verification reached the stated level. */ + data class Success(val verification: Verification) : Result + + /** The user cancelled; the drive holds a partial image. */ + data object Cancelled : Result + + /** Writing failed. [message] is localized and user-facing. */ + data class WriteError(val message: String) : Result + + /** + * The write completed but verification failed — either the download's + * checksum didn't match the manifest or the read-back didn't match + * what was written. The drive contents cannot be trusted; the caller + * should offer a retry. + */ + data class VerificationError(val message: String) : Result + } + + /** How far verification got on a successful flash. */ + enum class Verification { + /** Download matched the manifest checksum AND the read-back matched the write. */ + AUTHENTIC, + + /** No manifest checksum available (local image); the read-back matched the write. */ + WRITE_VERIFIED, + + /** The user chose to skip mid-verification. Skipping is not a failure. */ + SKIPPED + } + @Volatile private var isCancelled = false + @Volatile + private var isVerificationSkipped = false + fun cancel() { isCancelled = true } + /** Requests that an in-flight verification pass end early (skip ≠ fail). */ + fun skipVerification() { + isVerificationSkipped = true + } + + /** + * Streams the recovery image at [url] onto [device], then verifies it. + * + * @param expectedZipSha1 The manifest's SHA-1 of the downloaded `.zip`, when + * flashing an official image. Null for local files, which get write + * verification only. + * @param onStep Human-readable phase announcements. + * @param onProgress Write progress, 0..1. + * @param onVerifyProgress Read-back verification progress, 0..1. + */ suspend fun flashImageToUsb( device: UsbDevice, url: String, + expectedZipSha1: String? = null, + expectedImageSize: Long? = null, onStep: (String) -> Unit, - onProgress: (Float) -> Unit - ): String? = withContext(Dispatchers.IO) { + onProgress: (Float) -> Unit, + onVerifyProgress: (Float) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { isCancelled = false - var usbConnection: android.hardware.usb.UsbDeviceConnection? = null - var massStorageInterface: android.hardware.usb.UsbInterface? = null + isVerificationSkipped = false + var usbConnection: UsbDeviceConnection? = null + var massStorageInterface: UsbInterface? = null var dataStream: InputStream? = null try { var isZip = url.endsWith(".zip", ignoreCase = true) - var displayName = "" val inputStream: InputStream val contentLength: Long if (url.startsWith("content://")) { onStep(context.getString(R.string.step_opening_local)) - val uri = android.net.Uri.parse(url) + val uri = Uri.parse(url) val cursor = context.contentResolver.query(uri, null, null, null, null) var size = 0L cursor?.use { if (it.moveToFirst()) { - val sizeIndex = it.getColumnIndex(android.provider.OpenableColumns.SIZE) + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) if (sizeIndex != -1) size = it.getLong(sizeIndex) - val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (nameIndex != -1) { - displayName = it.getString(nameIndex) ?: "" + val displayName = it.getString(nameIndex) ?: "" if (displayName.endsWith(".zip", ignoreCase = true)) isZip = true } } } - + if (size <= 0) { try { context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { afd -> @@ -74,10 +155,10 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex if (len > 0) size = len } } catch (e: Exception) { - Log.e("UsbFlasher", "Failed to get AssetFileDescriptor length: ${e.message}") + Log.e(TAG, "Failed to get AssetFileDescriptor length: ${e.message}") } } - + if (size <= 0) { try { context.contentResolver.openFileDescriptor(uri, "r")?.use { fd -> @@ -85,10 +166,10 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex if (len > 0) size = len } } catch (e: Exception) { - Log.e("UsbFlasher", "Failed to get statSize: ${e.message}") + Log.e(TAG, "Failed to get statSize: ${e.message}") } } - + if (size <= 0) { try { context.contentResolver.openInputStream(uri)?.use { stream -> @@ -96,19 +177,17 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex if (len > 0) size = len } } catch (e: Exception) { - Log.e("UsbFlasher", "Failed to get available(): ${e.message}") + Log.e(TAG, "Failed to get available(): ${e.message}") } } - - // If STILL 0, we can't reliably estimate. Set a safe fallback for local tests (e.g. 50MB instead of 4GB) - // so it doesn't appear entirely stuck, but realistically size should be found by now. + if (size <= 0) { - Log.e("UsbFlasher", "Absolutely failed to find file size. Using fallback.") + Log.e(TAG, "Absolutely failed to find file size. Using fallback.") } - + contentLength = size - inputStream = context.contentResolver.openInputStream(uri) - ?: return@withContext context.getString(R.string.error_open_local) + inputStream = context.contentResolver.openInputStream(uri) + ?: return@withContext Result.WriteError(context.getString(R.string.error_open_local)) } else { onStep(context.getString(R.string.step_connecting_server)) val connection = URL(url).openConnection() as HttpURLConnection @@ -118,80 +197,72 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex connection.connect() if (connection.responseCode != HttpURLConnection.HTTP_OK) { - Log.e("UsbFlasher", "Server returned HTTP ${connection.responseCode}") - return@withContext context.getString(R.string.error_http_server, connection.responseCode) + Log.e(TAG, "Server returned HTTP ${connection.responseCode}") + return@withContext Result.WriteError( + context.getString(R.string.error_http_server, connection.responseCode) + ) } - contentLength = connection.contentLength.toLong() + // contentLength (Int) overflows on >2GB downloads; several official + // images exceed that compressed. + contentLength = connection.contentLengthLong inputStream = connection.inputStream } - var estimatedUncompressedSize: Long = if (contentLength > 0) contentLength * 3 else 4L * 1024 * 1024 * 1024 // fallback to 4GB + // When the manifest gave us a checksum, hash the raw (compressed) stream + // as it flows past — by the time the write finishes we have the digest of + // the entire download for the authenticity check. + val sourceDigest = if (expectedZipSha1 != null) IncrementalDigest() else null + val sourceStream: InputStream = + if (sourceDigest != null) DigestingInputStream(inputStream, sourceDigest) else inputStream + + // Progress denominator, best source first: the manifest's exact + // uncompressed size, then the zip entry header, then a 3x-compressed + // guess from the download size, then a 4GB fallback. + var estimatedUncompressedSize: Long = when { + expectedImageSize != null && expectedImageSize > 0 -> expectedImageSize + contentLength > 0 -> contentLength * 3 + else -> 4L * 1024 * 1024 * 1024 + } if (isZip) { onStep(if (url.startsWith("content://")) context.getString(R.string.step_unpacking_local) else context.getString(R.string.step_downloading_unpacking)) - val zipInputStream = ZipInputStream(inputStream) + val zipInputStream = ZipInputStream(sourceStream) var entry = zipInputStream.nextEntry while (entry != null) { if (!entry.isDirectory && entry.name.endsWith(".bin", ignoreCase = true)) { - Log.i("UsbFlasher", "Found binary image: ${entry.name}") - if (entry.size > 0) estimatedUncompressedSize = entry.size + Log.i(TAG, "Found binary image: ${entry.name}") + if (expectedImageSize == null && entry.size > 0) estimatedUncompressedSize = entry.size break } entry = zipInputStream.nextEntry } if (entry == null) { - Log.e("UsbFlasher", "No .bin file found in the downloaded zip.") + Log.e(TAG, "No .bin file found in the downloaded zip.") zipInputStream.close() - return@withContext context.getString(R.string.error_invalid_zip) + return@withContext Result.WriteError(context.getString(R.string.error_invalid_zip)) } dataStream = zipInputStream } else { onStep(if (url.startsWith("content://")) context.getString(R.string.step_reading_local) else context.getString(R.string.step_downloading)) - if (contentLength > 0) estimatedUncompressedSize = contentLength - dataStream = inputStream + if (expectedImageSize == null && contentLength > 0) estimatedUncompressedSize = contentLength + dataStream = sourceStream } // At this point we have a stream of the decompressed .bin file. - usbConnection = usbManager.openDevice(device) - if (usbConnection == null) { - Log.e("UsbFlasher", "Permission denied for USB device.") - return@withContext context.getString(R.string.error_usb_permission) - } - - var endpointIn: android.hardware.usb.UsbEndpoint? = null - var endpointOut: android.hardware.usb.UsbEndpoint? = null - - for (i in 0 until device.interfaceCount) { - val intf = device.getInterface(i) - if (intf.interfaceClass == android.hardware.usb.UsbConstants.USB_CLASS_MASS_STORAGE) { - massStorageInterface = intf - for (j in 0 until intf.endpointCount) { - val ep = intf.getEndpoint(j) - if (ep.direction == android.hardware.usb.UsbConstants.USB_DIR_IN) { - endpointIn = ep - } else if (ep.direction == android.hardware.usb.UsbConstants.USB_DIR_OUT) { - endpointOut = ep - } - } - break - } + val claim = claimMassStorage(device) + if (claim is Claim.Failed) { + return@withContext Result.WriteError(claim.message) } + val ok = claim as Claim.Ok + usbConnection = ok.connection + massStorageInterface = ok.usbInterface + val botDevice = ok.bot - if (massStorageInterface == null || endpointIn == null || endpointOut == null) { - Log.e("UsbFlasher", "Could not find mass storage interface or endpoints.") - return@withContext context.getString(R.string.error_not_mass_storage) - } - - if (!usbConnection.claimInterface(massStorageInterface, true)) { - Log.e("UsbFlasher", "Could not claim mass storage interface.") - return@withContext context.getString(R.string.error_claim_interface) - } - - val botDevice = com.google.chrome.recovery.usb.bot.BotDevice(usbConnection, massStorageInterface!!, endpointIn, endpointOut) - - // Simulate the streaming write process + // Stream the image to the drive, digesting the decompressed bytes as they + // pass — this digest is the reference the read-back is checked against. onStep(context.getString(R.string.step_writing_usb)) + val writtenDigest = IncrementalDigest() var totalRead: Long = 0 var currentLba = 0 var lastUpdateMs = System.currentTimeMillis() @@ -200,12 +271,14 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex val chunkBuffer = ByteArray(1024 * 128) var chunkPos = 0 - var bytesRead = dataStream!!.read(readBuffer) + var bytesRead = dataStream.read(readBuffer) while (bytesRead != -1) { if (isCancelled) { - return@withContext "Cancelled" + return@withContext Result.Cancelled } - + + writtenDigest.update(readBuffer, 0, bytesRead) + // Append read bytes to chunkBuffer System.arraycopy(readBuffer, 0, chunkBuffer, chunkPos, bytesRead) chunkPos += bytesRead @@ -218,8 +291,8 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex System.arraycopy(chunkBuffer, 0, dataToWrite, 0, bytesToWrite) if (!botDevice.writeSectors(currentLba, dataToWrite)) { - Log.e("UsbFlasher", "Failed to write sectors at LBA $currentLba") - return@withContext context.getString(R.string.error_hardware_write) + Log.e(TAG, "Failed to write sectors at LBA $currentLba") + return@withContext Result.WriteError(context.getString(R.string.error_hardware_write)) } currentLba += bytesToWrite / 512 @@ -229,17 +302,16 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex } chunkPos = remainder } - + // Update progress occasionally val now = System.currentTimeMillis() if (now - lastUpdateMs > 500) { // every 500ms lastUpdateMs = now val progress = (totalRead.toDouble() / estimatedUncompressedSize.toDouble()).toFloat() - Log.d("UsbFlasher", "Progress: $totalRead / $estimatedUncompressedSize ($progress)") onProgress(progress.coerceIn(0f, 1f)) } - - bytesRead = dataStream!!.read(readBuffer) + + bytesRead = dataStream.read(readBuffer) } // Write any remaining data @@ -249,30 +321,85 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex System.arraycopy(chunkBuffer, 0, dataToWrite, 0, chunkPos) // padding is automatically 0 since ByteArray initializes to 0 if (!botDevice.writeSectors(currentLba, dataToWrite)) { - Log.e("UsbFlasher", "Failed to write final sectors at LBA $currentLba") - return@withContext context.getString(R.string.error_drive_too_small) + Log.e(TAG, "Failed to write final sectors at LBA $currentLba") + return@withContext Result.WriteError(context.getString(R.string.error_drive_too_small)) } } - + + // Authenticity: the zip's trailing bytes (central directory) sit past the + // .bin entry, so drain the source to finish the download digest before + // comparing against the manifest checksum. + if (sourceDigest != null) { + val drain = ByteArray(1024 * 64) + while (sourceStream.read(drain) != -1) { + if (isCancelled) return@withContext Result.Cancelled + } + val downloadHex = sourceDigest.hexDigest() + if (!IncrementalDigest.matches(expectedZipSha1, downloadHex)) { + Log.e(TAG, "Download checksum mismatch: manifest=$expectedZipSha1 actual=$downloadHex") + return@withContext Result.VerificationError( + context.getString(R.string.error_authenticity_failed) + ) + } + } + + // Write verification: read back exactly the bytes written (whole sectors + // off the wire, but only totalRead bytes into the digest — the final + // sector's padding was never part of the image) and compare digests. onStep(context.getString(R.string.step_verifying)) - onProgress(0f) - // Verification: read the first 128 sectors (64KB) to ensure we wrote successfully - val verifyData = botDevice.readSectors(0, 128) - if (verifyData == null) { - Log.e("UsbFlasher", "Verification failed: could not read from USB.") - return@withContext context.getString(R.string.error_verification_failed) + onVerifyProgress(0f) + + val expectedWriteHex = writtenDigest.hexDigest() + val readBack = BoundedDigest(totalRead) + val totalSectors = (totalRead + SECTOR_SIZE - 1) / SECTOR_SIZE + var lba = 0L + var lastVerifyUpdateMs = System.currentTimeMillis() + + while (lba < totalSectors) { + if (isCancelled) { + return@withContext Result.Cancelled + } + if (isVerificationSkipped) { + return@withContext Result.Success(Verification.SKIPPED) + } + + val sectors = minOf(VERIFY_CHUNK_SECTORS.toLong(), totalSectors - lba).toInt() + val data = botDevice.readSectors(lba.toInt(), sectors) + ?: return@withContext Result.VerificationError( + context.getString(R.string.error_verification_failed) + ) + readBack.offer(data, data.size) + lba += sectors + + val now = System.currentTimeMillis() + if (now - lastVerifyUpdateMs > 500) { + lastVerifyUpdateMs = now + onVerifyProgress((readBack.bytesDigested.toDouble() / totalRead.toDouble()).toFloat().coerceIn(0f, 1f)) + } + } + onVerifyProgress(1f) + + if (!IncrementalDigest.matches(expectedWriteHex, readBack.hexDigest())) { + Log.e(TAG, "Read-back digest mismatch: written=$expectedWriteHex readback=${readBack.hexDigest()}") + return@withContext Result.VerificationError( + context.getString(R.string.error_verification_failed) + ) } - return@withContext null // Success! + return@withContext Result.Success( + if (expectedZipSha1 != null) Verification.AUTHENTIC else Verification.WRITE_VERIFIED + ) } catch (e: Exception) { - Log.e("UsbFlasher", "Error flashing to USB", e) - return@withContext context.getString(R.string.error_unexpected, e.message ?: e.javaClass.simpleName) + Log.e(TAG, "Error flashing to USB", e) + return@withContext Result.WriteError( + context.getString(R.string.error_unexpected, e.message ?: e.javaClass.simpleName) + ) } finally { try { dataStream?.close() } catch (e: Exception) { - Log.e("UsbFlasher", "Failed to close input stream", e) + Log.e(TAG, "Failed to close input stream", e) } try { if (massStorageInterface != null) { @@ -280,8 +407,184 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex } usbConnection?.close() } catch (e: Exception) { - Log.e("UsbFlasher", "Failed to close USB connection", e) + Log.e(TAG, "Failed to close USB connection", e) + } + } + } + + /** Terminal outcome of an erase. */ + sealed interface EraseResult { + data object Done : EraseResult + data class Error(val message: String) : EraseResult + } + + /** + * Erases the drive by zeroing its partition structures: the first 16 MiB + * (MBR/primary GPT plus early filesystem metadata) and, when the drive's + * capacity is readable, the last 1 MiB (backup GPT). Operating systems then + * see a blank drive and offer to format it. + * + * This is a signature wipe, not data destruction — matching what the + * desktop Chromebook Recovery Utility's erase does, minus its FAT32 + * reformat (a possible follow-up). It takes seconds, so unlike the flash + * it runs without the KeepAlive foreground service. + */ + suspend fun eraseDevice( + device: UsbDevice, + onProgress: (Float) -> Unit + ): EraseResult = withContext(Dispatchers.IO) { + var connection: UsbDeviceConnection? = null + var claimedInterface: UsbInterface? = null + try { + val claim = claimMassStorage(device) + if (claim is Claim.Failed) { + return@withContext EraseResult.Error(claim.message) + } + val ok = claim as Claim.Ok + connection = ok.connection + claimedInterface = ok.usbInterface + val bot = ok.bot + + val capacity = bot.readCapacity() + if (capacity == null) { + Log.w(TAG, "READ CAPACITY failed; wiping the head only.") + } + + val headSectors = HEAD_WIPE_BYTES / SECTOR_SIZE + val headToWipe = capacity?.let { minOf(headSectors, it.totalSectors) } ?: headSectors + val tailSectors = + if (capacity != null && capacity.totalSectors > headSectors + TAIL_WIPE_BYTES / SECTOR_SIZE) { + TAIL_WIPE_BYTES / SECTOR_SIZE + } else 0L + val totalToWipe = headToWipe + tailSectors + var wiped = 0L + val zeros = ByteArray(VERIFY_CHUNK_SECTORS * SECTOR_SIZE) + + fun wipeRange(startLba: Long, count: Long): Boolean { + var lba = startLba + val end = startLba + count + while (lba < end) { + val sectors = minOf(VERIFY_CHUNK_SECTORS.toLong(), end - lba).toInt() + val buffer = if (sectors == VERIFY_CHUNK_SECTORS) zeros else ByteArray(sectors * SECTOR_SIZE) + if (!bot.writeSectors(lba.toInt(), buffer)) { + Log.e(TAG, "Failed to zero sectors at LBA $lba") + return false + } + lba += sectors + wiped += sectors + onProgress((wiped.toDouble() / totalToWipe).toFloat().coerceIn(0f, 1f)) + } + return true + } + + if (!wipeRange(0, headToWipe)) { + return@withContext EraseResult.Error(context.getString(R.string.error_erase_failed)) + } + if (tailSectors > 0 && capacity != null) { + if (!wipeRange(capacity.totalSectors - tailSectors, tailSectors)) { + return@withContext EraseResult.Error(context.getString(R.string.error_erase_failed)) + } + } + onProgress(1f) + EraseResult.Done + } catch (e: Exception) { + Log.e(TAG, "Error erasing USB device", e) + EraseResult.Error(context.getString(R.string.error_unexpected, e.message ?: e.javaClass.simpleName)) + } finally { + try { + if (claimedInterface != null) { + connection?.releaseInterface(claimedInterface) + } + connection?.close() + } catch (e: Exception) { + Log.e(TAG, "Failed to close USB connection", e) + } + } + } + + private sealed interface Claim { + class Ok( + val connection: UsbDeviceConnection, + val usbInterface: UsbInterface, + val bot: BotDevice + ) : Claim + + class Failed(val message: String) : Claim + } + + /** + * Opens [device], finds the mass-storage interface and its bulk endpoints, + * and claims it. On failure the connection is closed and a localized, + * user-facing message is returned. + */ + private fun claimMassStorage(device: UsbDevice): Claim { + val connection = usbManager.openDevice(device) + if (connection == null) { + Log.e(TAG, "Permission denied for USB device.") + return Claim.Failed(context.getString(R.string.error_usb_permission)) + } + + var massStorageInterface: UsbInterface? = null + var endpointIn: UsbEndpoint? = null + var endpointOut: UsbEndpoint? = null + + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + if (intf.interfaceClass == UsbConstants.USB_CLASS_MASS_STORAGE) { + massStorageInterface = intf + for (j in 0 until intf.endpointCount) { + val ep = intf.getEndpoint(j) + if (ep.direction == UsbConstants.USB_DIR_IN) { + endpointIn = ep + } else if (ep.direction == UsbConstants.USB_DIR_OUT) { + endpointOut = ep + } + } + break + } + } + + if (massStorageInterface == null || endpointIn == null || endpointOut == null) { + Log.e(TAG, "Could not find mass storage interface or endpoints.") + connection.close() + return Claim.Failed(context.getString(R.string.error_not_mass_storage)) + } + + if (!connection.claimInterface(massStorageInterface, true)) { + Log.e(TAG, "Could not claim mass storage interface.") + connection.close() + return Claim.Failed(context.getString(R.string.error_claim_interface)) + } + + return Claim.Ok(connection, massStorageInterface, BotDevice(connection, massStorageInterface, endpointIn, endpointOut)) + } + + /** + * Passes every byte read through [digest]. Used to hash the raw download + * while ZipInputStream consumes it on the fly. + */ + private class DigestingInputStream( + source: InputStream, + private val digest: IncrementalDigest + ) : FilterInputStream(source) { + + private val single = ByteArray(1) + + override fun read(): Int { + val value = super.read() + if (value != -1) { + single[0] = value.toByte() + digest.update(single, 0, 1) + } + return value + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val count = super.read(b, off, len) + if (count > 0) { + digest.update(b, off, count) } + return count } } } diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/bot/BotDevice.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/bot/BotDevice.kt index 6d980ab..d75ae2d 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/usb/bot/BotDevice.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/bot/BotDevice.kt @@ -54,19 +54,75 @@ class BotDevice( offset += toWrite } - // Read CSW + // Read CSW. bulkTransfer always writes at the buffer's start, so partial + // reads must accumulate through a scratch buffer — reading into csw + // directly a second time would overwrite the bytes already collected. val csw = ByteArray(13) + val scratch = ByteArray(13) var cswRead = 0 while (cswRead < 13) { - val res = connection.bulkTransfer(endpointIn, csw, 13 - cswRead, 10000) // 10s timeout for flash writes + val res = connection.bulkTransfer(endpointIn, scratch, 13 - cswRead, 10000) // 10s timeout for flash writes if (res < 0) return false - // Shift data if it was a short read, though CSW is usually one packet - if (cswRead > 0) System.arraycopy(csw, 0, csw, cswRead, res) + System.arraycopy(scratch, 0, csw, cswRead, res) cswRead += res } - return cswRead == 13 && csw[12] == 0.toByte() // Status == 0 (Passed) + return csw[12] == 0.toByte() // Status == 0 (Passed) } + /** + * SCSI READ CAPACITY(10): returns the drive's total sector count and sector + * size, or null on failure. Needed to locate structures at the end of the + * disk (e.g. the backup GPT) without guessing the drive's size. + */ + fun readCapacity(): Capacity? { + val cbw = ByteBuffer.allocate(31).order(ByteOrder.LITTLE_ENDIAN) + cbw.putInt(0x43425355) // Signature "USBC" + cbw.putInt(++tag) + cbw.putInt(8) // DataTransferLength: 8-byte capacity structure + cbw.put(0x80.toByte()) // Flags (in from device) + cbw.put(0x00.toByte()) // LUN + cbw.put(10.toByte()) // Command Length + cbw.put(0x25.toByte()) // READ CAPACITY(10) opcode + while (cbw.position() < 31) cbw.put(0x00.toByte()) + + if (connection.bulkTransfer(endpointOut, cbw.array(), 31, 5000) != 31) return null + + val data = ByteArray(8) + var read = 0 + val scratch = ByteArray(8) + while (read < 8) { + val res = connection.bulkTransfer(endpointIn, scratch, 8 - read, 5000) + if (res <= 0) return null + System.arraycopy(scratch, 0, data, read, res) + read += res + } + + val csw = ByteArray(13) + val cswScratch = ByteArray(13) + var cswRead = 0 + while (cswRead < 13) { + val res = connection.bulkTransfer(endpointIn, cswScratch, 13 - cswRead, 5000) + if (res < 0) return null + System.arraycopy(cswScratch, 0, csw, cswRead, res) + cswRead += res + } + if (csw[12] != 0.toByte()) return null + + // Both fields are big-endian; the LBA field is the LAST addressable + // sector, so total count is +1. Mask to keep the unsigned range. + val lastLba = ((data[0].toLong() and 0xff) shl 24) or + ((data[1].toLong() and 0xff) shl 16) or + ((data[2].toLong() and 0xff) shl 8) or + (data[3].toLong() and 0xff) + val blockSize = ((data[4].toInt() and 0xff) shl 24) or + ((data[5].toInt() and 0xff) shl 16) or + ((data[6].toInt() and 0xff) shl 8) or + (data[7].toInt() and 0xff) + return Capacity(totalSectors = lastLba + 1, sectorSize = blockSize) + } + + data class Capacity(val totalSectors: Long, val sectorSize: Int) + fun readSectors(lba: Int, numSectors: Int): ByteArray? { val dataSize = numSectors * 512 @@ -91,6 +147,8 @@ class BotDevice( if (connection.bulkTransfer(endpointOut, cbw.array(), 31, 5000) != 31) return null + // Read the data phase in 16KB bulk transfers. Short reads are legal on + // bulk IN endpoints (packet boundaries), so accumulate rather than fail. val data = ByteArray(dataSize) val chunkSize = 16384 var offset = 0 @@ -98,20 +156,21 @@ class BotDevice( val toRead = Math.min(chunkSize, dataSize - offset) val chunk = ByteArray(toRead) val result = connection.bulkTransfer(endpointIn, chunk, toRead, 5000) - if (result != toRead) return null - System.arraycopy(chunk, 0, data, offset, toRead) - offset += toRead + if (result <= 0) return null + System.arraycopy(chunk, 0, data, offset, result) + offset += result } val csw = ByteArray(13) + val scratch = ByteArray(13) var cswRead = 0 while (cswRead < 13) { - val res = connection.bulkTransfer(endpointIn, csw, 13 - cswRead, 5000) + val res = connection.bulkTransfer(endpointIn, scratch, 13 - cswRead, 5000) if (res < 0) return null - if (cswRead > 0) System.arraycopy(csw, 0, csw, cswRead, res) + System.arraycopy(scratch, 0, csw, cswRead, res) cswRead += res } - if (cswRead != 13 || csw[12] != 0.toByte()) return null + if (csw[12] != 0.toByte()) return null return data } diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/verify/Digests.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/verify/Digests.kt new file mode 100644 index 0000000..1a394e6 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/verify/Digests.kt @@ -0,0 +1,68 @@ +package com.google.chrome.recovery.usb.verify + +import java.security.MessageDigest + +/** + * Small, pure-Kotlin digest helpers for flash verification. No Android or USB + * types here on purpose: everything in this file is exercised by plain JVM + * unit tests, while UsbFlasher supplies the bytes. + */ + +/** + * Incrementally computes a digest over a stream of chunks and renders it as + * lowercase hex — the format the ChromeOS recovery manifest uses for its + * `sha1`/`md5` fields. + */ +class IncrementalDigest(algorithm: String = "SHA-1") { + + private val digest = MessageDigest.getInstance(algorithm) + + fun update(buffer: ByteArray, offset: Int = 0, length: Int = buffer.size) { + digest.update(buffer, offset, length) + } + + /** Finishes the computation. The instance must not be updated afterwards. */ + fun hexDigest(): String = digest.digest().joinToString("") { "%02x".format(it) } + + companion object { + /** + * Manifest checksums are lowercase hex, but nothing guarantees casing; + * compare case-insensitively and reject blank expectations outright. + */ + fun matches(expectedHex: String?, actualHex: String): Boolean = + !expectedHex.isNullOrBlank() && expectedHex.equals(actualHex, ignoreCase = true) + } +} + +/** + * Digests exactly [totalBytes] bytes from the chunks offered to it, ignoring + * any excess. The USB read-back path works in whole 512-byte sectors, so the + * final chunk usually carries padding past the true image length — padding + * that was never part of the source data and must not enter the digest. + */ +class BoundedDigest(private val totalBytes: Long, algorithm: String = "SHA-1") { + + private val digest = IncrementalDigest(algorithm) + + /** Bytes digested so far; stops growing once [totalBytes] is reached. */ + var bytesDigested: Long = 0 + private set + + val isComplete: Boolean + get() = bytesDigested >= totalBytes + + /** + * Offers a chunk. Only the portion that fits under the [totalBytes] bound + * is digested; the rest is ignored. Returns the number of bytes consumed. + */ + fun offer(buffer: ByteArray, length: Int = buffer.size): Int { + val remaining = totalBytes - bytesDigested + if (remaining <= 0) return 0 + val toDigest = minOf(remaining, length.toLong()).toInt() + digest.update(buffer, 0, toDigest) + bytesDigested += toDigest + return toDigest + } + + fun hexDigest(): String = digest.hexDigest() +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5261bcc..a530373 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ Book Recovery + + Opening local image… Connecting to download server… Unpacking local image… @@ -10,6 +12,7 @@ Writing to USB… Verifying… + Could not open the selected local file. It may have been moved or deleted. Download server returned an error: HTTP %d The selected ZIP file does not contain a valid Chrome OS recovery image (.bin). @@ -19,12 +22,121 @@ Hardware write error. The USB drive might be corrupted, full, or write-protected. Hardware write error at the end of the drive. The USB drive might not be large enough. Verification failed. The data written to the USB drive could not be read back correctly. + The downloaded image did not match Google\'s published checksum. The download may have been corrupted. + Could not erase the drive. It may be write-protected, busy, or disconnected. An unexpected error occurred: %s - + Step %1$d of %2$d Home Back More options Erase recovery media + + + Continue + OK + Back to Home + + + Create recovery media for your Book + You will need an 8 GB or larger USB flash drive or SD card that you don\'t mind erasing. + Get started + + + Identify your Book + Enter the model number of the Book you want to recover. + Type model number + Model not found. Please check and try again. + Valid Model + How to find this? + How to find your model number + The model number is located at the bottom of the recovery screen on your Book. + Example: \nPEPPY C6A-V7C-A5Q + OR + Select a model from a list + Use local image + + + Select the manufacturer and product. + Select a manufacturer + Select a product + Search models + Select a model from the list to see available channels. + + + Select a channel + Choose the recovery channel for %1$s. + No channels found for this model. + STABLE + + + Insert your USB flash drive or SD card + Select the media you\'d like to use. + Please insert a USB drive. + Unknown USB Device + Manufacturer: %1$s + Unknown + Erase drive before proceeding? + Do you want to erase the drive before writing the recovery image? This clears the drive\'s partition information and can resolve issues with previously used media. + Yes, erase first + Skip and proceed + Permission Required + Permission denied for USB device. + + + Erasing... + Success! Your recovery media has been erased. + The drive\'s partition information was cleared. Your computer may ask to format it before its next use. + Please do not remove your recovery media. + + + Starting... + Erasing media... + Success! Your USB has been reset. + Success! Your recovery media is ready. + Success! Your recovery media is ready and matches Google\'s published checksum. + Success! Your recovery media is ready. Write verification passed. + Skip verification + Retry flash + Error: %1$s + Cancel and reset USB Stick + Success + Error + %1$d%% + + + Flash Progress + Preparing to flash + Initializing... + Started + %1$.1f%% complete + Erasing media... + Erasing + Verifying media... + Verifying + Recovery Media Ready + Error creating media + + + + Flashing... + Fluxabilating... + Gone fishing... + Crunching bytes... + Reticulating splines... + Writing magic... + Doing the heavy lifting... + + + Working + Writing + Wait... + Almost + Hold on + Steady + Busy + diff --git a/android/app/src/test/java/com/google/chrome/recovery/usb/verify/DigestsTest.kt b/android/app/src/test/java/com/google/chrome/recovery/usb/verify/DigestsTest.kt new file mode 100644 index 0000000..764905f --- /dev/null +++ b/android/app/src/test/java/com/google/chrome/recovery/usb/verify/DigestsTest.kt @@ -0,0 +1,118 @@ +package com.google.chrome.recovery.usb.verify + +import java.security.MessageDigest +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IncrementalDigestTest { + + @Test + fun `sha1 of known vector matches`() { + val digest = IncrementalDigest("SHA-1") + digest.update("abc".toByteArray()) + // FIPS 180 test vector. + assertEquals("a9993e364706816aba3e25717850c26c9cd0d89d", digest.hexDigest()) + } + + @Test + fun `md5 of known vector matches`() { + val digest = IncrementalDigest("MD5") + digest.update("abc".toByteArray()) + // RFC 1321 test vector. + assertEquals("900150983cd24fb0d6963f7d28e17f72", digest.hexDigest()) + } + + @Test + fun `chunked updates equal a single update`() { + val data = Random(42).nextBytes(1_000_003) // deliberately not sector-aligned + val whole = IncrementalDigest().apply { update(data) } + + val chunked = IncrementalDigest() + var offset = 0 + val chunkSizes = intArrayOf(1, 511, 512, 513, 65536) + var i = 0 + while (offset < data.size) { + val len = minOf(chunkSizes[i % chunkSizes.size], data.size - offset) + chunked.update(data, offset, len) + offset += len + i++ + } + + assertEquals(whole.hexDigest(), chunked.hexDigest()) + } + + @Test + fun `hex output is lowercase and zero padded`() { + // SHA-1 of the empty input starts with "da39a3ee" and contains bytes < 0x10. + assertEquals("da39a3ee5e6b4b0d3255bfef95601890afd80709", IncrementalDigest().hexDigest()) + } + + @Test + fun `matches is case insensitive and rejects blank or null expectations`() { + assertTrue(IncrementalDigest.matches("ABCDEF01", "abcdef01")) + assertTrue(IncrementalDigest.matches("abcdef01", "abcdef01")) + assertFalse(IncrementalDigest.matches("abcdef01", "abcdef02")) + assertFalse(IncrementalDigest.matches(null, "abcdef01")) + assertFalse(IncrementalDigest.matches("", "abcdef01")) + assertFalse(IncrementalDigest.matches(" ", "abcdef01")) + } +} + +class BoundedDigestTest { + + private fun sha1(data: ByteArray): String = + MessageDigest.getInstance("SHA-1").digest(data).joinToString("") { "%02x".format(it) } + + @Test + fun `ignores sector padding past the bound`() { + val payload = "hello, recovery image".toByteArray() + val padded = payload.copyOf(512) // zero-padded to a full sector, like the final write + + val bounded = BoundedDigest(totalBytes = payload.size.toLong()) + bounded.offer(padded) + + assertEquals(sha1(payload), bounded.hexDigest()) + assertTrue(bounded.isComplete) + } + + @Test + fun `digests across chunks and stops exactly at the bound`() { + val data = Random(7).nextBytes(200_000) + val totalBytes = 130_999L // lands mid-chunk, not sector aligned + + val bounded = BoundedDigest(totalBytes) + var offset = 0 + val chunk = ByteArray(65536) + while (offset < data.size) { + val len = minOf(chunk.size, data.size - offset) + System.arraycopy(data, offset, chunk, 0, len) + bounded.offer(chunk, len) + offset += len + } + + assertEquals(totalBytes, bounded.bytesDigested) + assertEquals(sha1(data.copyOf(totalBytes.toInt())), bounded.hexDigest()) + } + + @Test + fun `offers after completion consume nothing`() { + val bounded = BoundedDigest(totalBytes = 4) + assertEquals(4, bounded.offer(byteArrayOf(1, 2, 3, 4, 5))) + assertEquals(0, bounded.offer(byteArrayOf(6, 7))) + assertEquals(4, bounded.bytesDigested) + } + + @Test + fun `detects a single flipped byte`() { + val data = Random(13).nextBytes(4096) + val expected = BoundedDigest(data.size.toLong()).apply { offer(data) }.hexDigest() + + data[2048] = (data[2048] + 1).toByte() + val corrupted = BoundedDigest(data.size.toLong()).apply { offer(data) }.hexDigest() + + assertFalse(IncrementalDigest.matches(expected, corrupted)) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6e82f38..f992e76 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,5 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.3.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.android.application") version "9.2.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false } diff --git a/android/gradle.properties b/android/gradle.properties index 2c45e28..e696167 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true -android.suppressUnsupportedCompileSdk=36 kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 509c4a2..5b59ea8 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/test.kt b/android/test.kt deleted file mode 100644 index 8fa2812..0000000 --- a/android/test.kt +++ /dev/null @@ -1,4 +0,0 @@ -import android.app.Notification -fun main() { - println(Notification.EXTRA_REQUEST_PROMOTED_ONGOING) -} diff --git a/android/test_api36.kt b/android/test_api36.kt deleted file mode 100644 index a712cab..0000000 --- a/android/test_api36.kt +++ /dev/null @@ -1,11 +0,0 @@ -import android.app.Notification -import android.content.Context - -fun test(context: Context) { - val builder = Notification.Builder(context, "channel") - builder.setRequestPromotedOngoing(true) - val style = Notification.ProgressStyle() - style.setProgress(100, 50, false) - builder.setStyle(style) - builder.setShortCriticalText("50%") -}