From d9ef2900b55c2d530400cff215e863b175ad4727 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:33:26 -0700 Subject: [PATCH 01/10] chore: remove stray scratch files from module root test.kt and test_api36.kt were one-off probes for the API 36 Notification.ProgressStyle / setShortCriticalText APIs. They live outside any source set, so they never compiled into the app; delete them to keep the module root clean. --- android/test.kt | 4 ---- android/test_api36.kt | 11 ----------- 2 files changed, 15 deletions(-) delete mode 100644 android/test.kt delete mode 100644 android/test_api36.kt 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%") -} From 94efa301c638d178628780d1f17638ad4ec35577 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:36:37 -0700 Subject: [PATCH 02/10] refactor: replace fully-qualified inline references with imports FlashScreen.kt in particular had ~38 fully-qualified references (androidx.compose.ui.platform.LocalLifecycleOwner, android.app.PendingIntent, and friends) inline in the code. Promote them all to proper imports in FlashScreen, RecoveryApp, SelectDriveScreen, SelectModelScreen, UsbFlasher, and KeepAliveService so the code reads like idiomatic Kotlin. No behavior change; the compiled output is identical. --- .../google/chrome/recovery/ui/RecoveryApp.kt | 16 +- .../chrome/recovery/ui/screens/FlashScreen.kt | 182 ++++++++++-------- .../recovery/ui/screens/SelectDriveScreen.kt | 43 +++-- .../recovery/ui/screens/SelectModelScreen.kt | 5 +- .../chrome/recovery/usb/KeepAliveService.kt | 6 +- .../google/chrome/recovery/usb/UsbFlasher.kt | 29 +-- 6 files changed, 163 insertions(+), 118 deletions(-) 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..1a447cb 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,9 @@ 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.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -36,11 +40,11 @@ fun RecoveryApp() { val currentRoute = navBackStackEntry?.destination?.route var selectedUrl by remember { mutableStateOf(null) } - var selectedDevice by remember { mutableStateOf(null) } + var selectedDevice by remember { mutableStateOf(null) } var eraseFirst by remember { 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() @@ -121,7 +125,7 @@ fun RecoveryApp() { composable("identify") { IdentifyScreen( onNext = { modelName -> - navController.navigate("select_channel/${android.net.Uri.encode(modelName)}") + navController.navigate("select_channel/${Uri.encode(modelName)}") }, onSelectFromList = { navController.navigate("select_model") @@ -133,11 +137,11 @@ fun RecoveryApp() { } composable("select_model") { SelectModelScreen(onNext = { modelName -> - navController.navigate("select_channel/${android.net.Uri.encode(modelName)}") + navController.navigate("select_channel/${Uri.encode(modelName)}") }) } composable("select_channel/{modelName}") { backStackEntry -> - val modelName = android.net.Uri.decode(backStackEntry.arguments?.getString("modelName") ?: "") + val modelName = Uri.decode(backStackEntry.arguments?.getString("modelName") ?: "") SelectChannelScreen( modelName = modelName, onNext = { modelUrl -> 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..dc21bcc 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,5 +1,22 @@ package com.google.chrome.recovery.ui.screens +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.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.ViewTreeObserver +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -8,24 +25,31 @@ 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.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.google.chrome.recovery.MainActivity +import com.google.chrome.recovery.usb.KeepAliveService import com.google.chrome.recovery.usb.UsbFlasher +import java.util.Locale +import kotlinx.coroutines.delay /** * 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 + * 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 + * 4. Generating and updating an Android 16 (API 36) Live Update status bar chip using * `Notification.ProgressStyle()` to keep the user informed natively. * * @param url The local or remote URL pointing to the recovery image .zip file. @@ -39,7 +63,7 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF 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) } @@ -49,12 +73,12 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF val flasher = remember { UsbFlasher(usbManager, context) } 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) @@ -64,17 +88,17 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } val isBackgrounded = isLifecycleBackgrounded || !isWindowFocused - val isBackgroundedState = androidx.compose.runtime.rememberUpdatedState(isBackgrounded) + val isBackgroundedState = rememberUpdatedState(isBackgrounded) - val permissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + val permissionLauncher = rememberLauncherForActivityResult( + 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,58 +108,58 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val notificationManager = remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager } + val notificationManager = remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as 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 + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } - android.app.PendingIntent.getActivity( - context, 0, intent, android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } - val buildNotification: (Float, String, String, String) -> android.app.Notification = remember(context, pendingIntent) { + val buildNotification: (Float, String, String, String) -> 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") + if (Build.VERSION.SDK_INT >= 36) { + val nativeBuilder = Notification.Builder(context, "flash_progress") .setSmallIcon(android.R.drawable.stat_sys_download) .setContentTitle(title) .setContentText(text) - .setColor(android.graphics.Color.parseColor("#4285F4")) + .setColor(Color.parseColor("#4285F4")) .setOngoing(true) .setOnlyAlertOnce(true) - .setCategory(android.app.Notification.CATEGORY_PROGRESS) + .setCategory(Notification.CATEGORY_PROGRESS) .setContentIntent(pendingIntent) .setAutoCancel(true) .setProgress(100, (p * 100).toInt(), false) try { nativeBuilder.setShortCriticalText(chipText) - val progressStyle = android.app.Notification.ProgressStyle() + val progressStyle = Notification.ProgressStyle() progressStyle.setProgress((p * 100).toInt()) nativeBuilder.setStyle(progressStyle) - - val extras = android.os.Bundle() + + val extras = Bundle() extras.putBoolean("android.requestPromotedOngoing", true) nativeBuilder.addExtras(extras) } catch (e: Exception) { - android.util.Log.e("FlashScreen", "Error setting Live Update styles", e) + 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") + NotificationCompat.Builder(context, "flash_progress") .setSmallIcon(android.R.drawable.stat_sys_download) .setContentTitle(title) .setContentText(text) - .setColor(android.graphics.Color.parseColor("#4285F4")) + .setColor(Color.parseColor("#4285F4")) .setProgress(1000, (p * 1000).toInt(), false) .setOngoing(true) .setOnlyAlertOnce(true) - .setCategory(androidx.core.app.NotificationCompat.CATEGORY_PROGRESS) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) .setContentIntent(pendingIntent) .setAutoCancel(true) .build() @@ -147,17 +171,17 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF 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 (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) } } - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - val channel = android.app.NotificationChannel( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( "flash_progress", "Flash Progress", - android.app.NotificationManager.IMPORTANCE_DEFAULT + NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(channel) } @@ -165,17 +189,17 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF // 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) + KeepAliveService.currentNotification = initialNotif + + val serviceIntent = Intent(context, KeepAliveService::class.java) try { - if (android.os.Build.VERSION.SDK_INT >= 26) { + if (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) + Log.e("FlashScreen", "Could not start KeepAliveService", e) } } @@ -188,10 +212,10 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF 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) { + + val notif = buildNotification(p, currentFunWord, String.format(Locale.US, "%.1f%% complete", p * 100), chipText) + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notificationManager.notify(1001, notif) } } @@ -202,9 +226,9 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF 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) { + val notif = buildNotification(progress, "Erasing media...", String.format(Locale.US, "%.1f%% complete", progress * 100), "Erasing") + KeepAliveService.currentNotification = notif + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notificationManager.notify(1001, notif) } delay(30) @@ -228,39 +252,39 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF device = device, url = url, onStep = { step -> currentStep = step }, - onProgress = { p -> - progress = p + 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 text = String.format(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) { + KeepAliveService.currentNotification = notif + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notificationManager.notify(1001, notif) } } ) if (errorMsg == "Cancelled") { - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) + context.stopService(Intent(context, 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)) + context.stopService(Intent(context, KeepAliveService::class.java)) if (isBackgroundedState.value) { - val builder = androidx.core.app.NotificationCompat.Builder(context, "flash_progress") + val builder = 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) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notificationManager.notify(1001, builder.build()) } } @@ -268,16 +292,16 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF currentStep = "Error: $errorMsg" hasError = true isFinished = true - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) + context.stopService(Intent(context, KeepAliveService::class.java)) if (isBackgroundedState.value) { - val builder = androidx.core.app.NotificationCompat.Builder(context, "flash_progress") + val builder = 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) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notificationManager.notify(1001, builder.build()) } } @@ -291,29 +315,29 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF ) { if (isFinished) { if (!hasError) { - androidx.compose.material3.Icon( + 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) + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(64.dp).padding(bottom = 16.dp) ) } else { - androidx.compose.material3.Icon( + 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) + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(64.dp).padding(bottom = 16.dp) ) } } - androidx.compose.material3.Text( + 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 + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 32.dp), + textAlign = TextAlign.Center ) - + if (!isFinished && !isErasing) { LinearProgressIndicator( progress = { progress }, @@ -325,7 +349,7 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF modifier = Modifier.padding(top = 8.dp) ) Spacer(modifier = Modifier.height(24.dp)) - OutlinedButton(onClick = { + OutlinedButton(onClick = { flasher.cancel() currentStep = "Erasing media..." progress = 0f 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..434fd50 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,14 @@ 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 android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -16,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat @Composable fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, onEraseFirst: ((UsbDevice) -> Unit)? = null) { @@ -33,11 +40,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,7 +56,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, pendingAction = null } } else { - android.util.Log.d("USB", "permission denied for device $device") + Log.d("USB", "permission denied for device $device") permissionError = "Permission denied for USB device." pendingAction = null } @@ -57,12 +64,12 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } } } - 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,14 +82,14 @@ 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) } 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..88979f2 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 @@ -8,6 +8,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import com.google.chrome.recovery.data.RecoveryImage import com.google.chrome.recovery.data.RecoveryRepository @@ -98,7 +99,7 @@ fun SelectModelScreen(onNext: (String) -> Unit) { DropdownMenu( expanded = mfrExpanded, onDismissRequest = { mfrExpanded = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false), + properties = PopupProperties(focusable = false), modifier = Modifier.exposedDropdownSize() ) { filteredManufacturers.forEach { mfr -> @@ -143,7 +144,7 @@ fun SelectModelScreen(onNext: (String) -> Unit) { DropdownMenu( expanded = modelExpanded, onDismissRequest = { modelExpanded = false }, - properties = androidx.compose.ui.window.PopupProperties(focusable = false), + properties = PopupProperties(focusable = false), modifier = Modifier.exposedDropdownSize() ) { filteredModelNames.forEach { modelName -> 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..9898cec 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,12 +1,19 @@ 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 java.io.InputStream import java.net.HttpURLConnection import java.net.URL @@ -40,8 +47,8 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex onProgress: (Float) -> Unit ): String? = withContext(Dispatchers.IO) { isCancelled = false - var usbConnection: android.hardware.usb.UsbDeviceConnection? = null - var massStorageInterface: android.hardware.usb.UsbInterface? = null + var usbConnection: UsbDeviceConnection? = null + var massStorageInterface: UsbInterface? = null var dataStream: InputStream? = null try { @@ -52,14 +59,14 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex 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) ?: "" if (displayName.endsWith(".zip", ignoreCase = true)) isZip = true @@ -159,18 +166,18 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex return@withContext context.getString(R.string.error_usb_permission) } - var endpointIn: android.hardware.usb.UsbEndpoint? = null - var endpointOut: android.hardware.usb.UsbEndpoint? = null + var endpointIn: UsbEndpoint? = null + var endpointOut: 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) { + 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 == android.hardware.usb.UsbConstants.USB_DIR_IN) { + if (ep.direction == UsbConstants.USB_DIR_IN) { endpointIn = ep - } else if (ep.direction == android.hardware.usb.UsbConstants.USB_DIR_OUT) { + } else if (ep.direction == UsbConstants.USB_DIR_OUT) { endpointOut = ep } } @@ -188,7 +195,7 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex return@withContext context.getString(R.string.error_claim_interface) } - val botDevice = com.google.chrome.recovery.usb.bot.BotDevice(usbConnection, massStorageInterface!!, endpointIn, endpointOut) + val botDevice = BotDevice(usbConnection, massStorageInterface, endpointIn, endpointOut) // Simulate the streaming write process onStep(context.getString(R.string.step_writing_usb)) From 45fbc67c65226a2944df5ff467da5c6537283a1d Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:37:34 -0700 Subject: [PATCH 03/10] refactor: introduce type-safe navigation routes The navigation graph, the top-bar step indicator, and every navigate() call previously agreed on eight string literals by convention alone. A typo in any one of them would compile fine and fail at runtime. Add a sealed Route hierarchy in ui/Route.kt as the single source of truth: each destination exposes its route pattern, and SelectChannel (the only parameterized destination) exposes forModel() so the URL-encoding of the model-name argument lives in exactly one place. No behavior change; the route strings are byte-for-byte the same. --- .../google/chrome/recovery/ui/RecoveryApp.kt | 60 +++++++++---------- .../com/google/chrome/recovery/ui/Route.kt | 36 +++++++++++ 2 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 android/app/src/main/java/com/google/chrome/recovery/ui/Route.kt 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 1a447cb..5686d3f 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 @@ -48,7 +48,7 @@ fun RecoveryApp() { ) { uri -> if (uri != null) { selectedUrl = uri.toString() - navController.navigate("select_drive") + navController.navigate(Route.SelectDrive.pattern) } } @@ -57,15 +57,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 -> 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, @@ -81,9 +81,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 { @@ -106,7 +106,7 @@ fun RecoveryApp() { text = { Text(stringResource(R.string.action_erase_media)) }, onClick = { expanded = false - navController.navigate("erase_drive") + navController.navigate(Route.EraseDrive.pattern) } ) } @@ -116,84 +116,84 @@ fun RecoveryApp() { ) { innerPadding -> NavHost( navController = navController, - startDestination = "welcome", + startDestination = Route.Welcome.pattern, modifier = Modifier.padding(innerPadding) ) { - 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/${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") { + composable(Route.SelectModel.pattern) { SelectModelScreen(onNext = { modelName -> - navController.navigate("select_channel/${Uri.encode(modelName)}") + navController.navigate(Route.SelectChannel.forModel(modelName)) }) } - composable("select_channel/{modelName}") { backStackEntry -> - val modelName = 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") + 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!!, 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" + } +} From ea4b86e1a9543c70ff519545fd86de137f73e832 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:41:54 -0700 Subject: [PATCH 04/10] refactor: extract flash orchestration into FlashViewModel FlashScreen was a 353-line composable that managed the Foreground Service, built API-36 Live Update notifications, ran the erase simulation, drove UsbFlasher, and rendered the UI, all inline. Business logic in the view layer meant no state survival story of its own and no way to unit test the flow. Split it three ways: - FlashViewModel owns the state machine (erasing -> flashing -> success/error, plus the cancel-and-reset path) and exposes a single FlashUiState via StateFlow. Scoped to the flash NavBackStackEntry, so the running flash now survives configuration changes by design rather than relying on the activity's configChanges opt-out. - FlashNotificationController owns the notification channel, the KeepAliveService start/stop, and the Live Update / NotificationCompat builders. The ViewModel decides when to notify, never how. - FlashScreen renders FlashUiState. The only view concerns left are the POST_NOTIFICATIONS permission prompt (needs an Activity) and lifecycle/window-focus tracking (needs a View), both reported to the ViewModel. Also names the flasher's 'Cancelled' sentinel (UsbFlasher.RESULT_CANCELLED) instead of comparing a bare string literal across files. Behavior is unchanged: same state transitions, same notification timing and content, same service lifetime. --- android/app/build.gradle.kts | 2 + .../chrome/recovery/ui/screens/FlashScreen.kt | 305 +++--------------- .../recovery/ui/screens/FlashViewModel.kt | 188 +++++++++++ .../usb/FlashNotificationController.kt | 189 +++++++++++ .../google/chrome/recovery/usb/UsbFlasher.kt | 10 +- 5 files changed, 437 insertions(+), 257 deletions(-) create mode 100644 android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashViewModel.kt create mode 100644 android/app/src/main/java/com/google/chrome/recovery/usb/FlashNotificationController.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bfa1f87..b1b8436 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.activity:activity-compose:1.8.2") implementation(platform("androidx.compose:compose-bom:2024.04.01")) implementation("androidx.compose.ui:ui") 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 dc21bcc..11eedc5 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,19 +1,9 @@ package com.google.chrome.recovery.ui.screens 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.hardware.usb.UsbDevice -import android.hardware.usb.UsbManager import android.os.Build -import android.os.Bundle -import android.util.Log import android.view.ViewTreeObserver import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,48 +20,59 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import com.google.chrome.recovery.MainActivity -import com.google.chrome.recovery.usb.KeepAliveService -import com.google.chrome.recovery.usb.UsbFlasher -import java.util.Locale -import kotlinx.coroutines.delay +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel /** - * FlashScreen handles the core execution logic of the Book Recovery utility. + * FlashScreen renders the progress and outcome of the flash flow. * - * 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. + * 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) } + val viewModel: FlashViewModel = viewModel { + FlashViewModel( + application = checkNotNull(this[AndroidViewModelFactory.APPLICATION_KEY]), + device = device, + url = url, + 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 = LocalLifecycleOwner.current val view = LocalView.current @@ -87,13 +88,6 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val isBackgrounded = isLifecycleBackgrounded || !isWindowFocused - val isBackgroundedState = rememberUpdatedState(isBackgrounded) - - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission() - ) {} - DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { @@ -108,204 +102,9 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val notificationManager = remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } - - val pendingIntent = remember(context) { - 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 - ) - } - - val buildNotification: (Float, String, String, String) -> Notification = remember(context, pendingIntent) { - { p, title, text, chipText -> - if (Build.VERSION.SDK_INT >= 36) { - val nativeBuilder = Notification.Builder(context, "flash_progress") - .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, (p * 100).toInt(), false) - - try { - nativeBuilder.setShortCriticalText(chipText) - val progressStyle = Notification.ProgressStyle() - progressStyle.setProgress((p * 100).toInt()) - nativeBuilder.setStyle(progressStyle) - - val extras = Bundle() - extras.putBoolean("android.requestPromotedOngoing", true) - nativeBuilder.addExtras(extras) - } catch (e: Exception) { - Log.e("FlashScreen", "Error setting Live Update styles", e) - } - - val built = nativeBuilder.build() - built.flags = built.flags or 262144 - built - } else { - NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle(title) - .setContentText(text) - .setColor(Color.parseColor("#4285F4")) - .setProgress(1000, (p * 1000).toInt(), false) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(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 (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) - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - "flash_progress", - "Flash Progress", - 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") - 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("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(Locale.US, "%.1f%% complete", p * 100), chipText) - - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - } - } - - LaunchedEffect(isErasing) { - if (isErasing) { - currentStep = "Erasing media..." - for (i in 1..100) { - progress = i / 100f - val notif = buildNotification(progress, "Erasing media...", String.format(Locale.US, "%.1f%% complete", progress * 100), "Erasing") - KeepAliveService.currentNotification = notif - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == 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 - } - } - } - - 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(Locale.US, "%.1f%% complete", p * 100) - val notif = buildNotification(p, currentFunWord, text, chipText) - - KeepAliveService.currentNotification = notif - - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - } - ) - - if (errorMsg == "Cancelled") { - context.stopService(Intent(context, KeepAliveService::class.java)) - } else if (errorMsg == null) { - currentStep = "Success! Your recovery media is ready." - progress = 1f - isFinished = true - context.stopService(Intent(context, KeepAliveService::class.java)) - if (isBackgroundedState.value) { - val builder = 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 (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, builder.build()) - } - } - } else { - currentStep = "Error: $errorMsg" - hasError = true - isFinished = true - context.stopService(Intent(context, KeepAliveService::class.java)) - if (isBackgroundedState.value) { - val builder = 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 (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, builder.build()) - } - } - } + viewModel.onBackgroundedChanged(isBackgrounded) } Column( @@ -313,8 +112,8 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - if (isFinished) { - if (!hasError) { + if (uiState.isFinished) { + if (!uiState.hasError) { Icon( imageVector = Icons.Filled.CheckCircle, contentDescription = "Success", @@ -332,39 +131,33 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } Text( - text = currentStep, + text = uiState.stepText, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 32.dp), textAlign = TextAlign.Center ) - if (!isFinished && !isErasing) { + if (!uiState.isFinished && !uiState.isErasing) { LinearProgressIndicator( - progress = { progress }, + progress = { uiState.progress }, modifier = Modifier.fillMaxWidth().height(8.dp) ) Text( - text = "${(progress * 100).toInt()}%", + text = "${(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 - }) { + OutlinedButton(onClick = { viewModel.cancelFlashAndReset() }) { Text("Cancel and reset USB Stick") } - } else if (isErasing && !isFinished) { + } else if (uiState.isErasing && !uiState.isFinished) { LinearProgressIndicator( - progress = { progress }, + progress = { uiState.progress }, modifier = Modifier.fillMaxWidth().height(8.dp) ) Text( - text = "${(progress * 100).toInt()}%", + text = "${(uiState.progress * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) 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..05d0b47 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashViewModel.kt @@ -0,0 +1,188 @@ +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.usb.FlashNotificationController +import com.google.chrome.recovery.usb.UsbFlasher +import kotlinx.coroutines.delay +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 Overall progress of the current phase, 0f..1f. + * @param isErasing True while the pre-flash (or cancel-triggered) erase pass 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. + */ +data class FlashUiState( + val stepText: String = "Starting...", + val progress: Float = 0f, + val isErasing: Boolean = false, + val isFinished: Boolean = false, + val hasError: Boolean = false, +) + +/** + * ViewModel owning the flash execution state machine: + * + * (erasing) -> flashing -> success / error + * | + * +-- 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]; the only view concerns remaining there are the + * POST_NOTIFICATIONS permission prompt and window-focus tracking, which need + * an Activity and a View respectively. + * + * Scoped to the flash destination's NavBackStackEntry, so the running flash + * survives rotation/resize and is torn down when the user leaves the screen. + * + * @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 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 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(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) { + simulateErase() + } else { + flash() + } + } + + /** + * Cancels the in-flight write, then runs the erase simulation so the user + * ends on a "USB reset" summary rather than a half-written drive left silently. + */ + fun cancelFlashAndReset() { + flasher.cancel() + isCancelledReset = true + _uiState.update { it.copy(stepText = "Erasing media...", progress = 0f, isErasing = true) } + simulateErase() + } + + /** + * 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.isErasing) + } + } + + /** + * Simulated erase pass (about 3 seconds of ramping progress). Ends either in + * the cancel-reset summary or by handing off to the real flash, depending on + * whether it was entered via [cancelFlashAndReset] or eraseFirst. + */ + private fun simulateErase() { + viewModelScope.launch { + _uiState.update { it.copy(stepText = "Erasing media...", isErasing = true) } + for (i in 1..100) { + val p = i / 100f + _uiState.update { it.copy(progress = p) } + notifications.postProgress(p, isErasing = true) + delay(30) + } + if (isCancelledReset) { + _uiState.update { + it.copy(stepText = "Success! Your USB has been reset.", progress = 1f, isFinished = true, hasError = false) + } + } else { + _uiState.update { it.copy(isErasing = false) } + flash() + } + } + } + + private fun flash() { + viewModelScope.launch { + val errorMsg = flasher.flashImageToUsb( + device = device, + url = url, + onStep = { step -> _uiState.update { it.copy(stepText = step) } }, + onProgress = { p -> + _uiState.update { it.copy(progress = p) } + notifications.postProgress(p, isErasing = false) + } + ) + + when { + errorMsg == UsbFlasher.RESULT_CANCELLED -> { + // cancelFlashAndReset() already owns the UI from here; just drop priority. + notifications.stopKeepAlive() + } + errorMsg == null -> { + val message = "Success! Your recovery media is ready." + _uiState.update { it.copy(stepText = message, progress = 1f, isFinished = true) } + notifications.stopKeepAlive() + if (isBackgrounded) { + notifications.postCompletion("Recovery Media Ready", message, isError = false) + } + } + else -> { + _uiState.update { it.copy(stepText = "Error: $errorMsg", isFinished = true, hasError = true) } + notifications.stopKeepAlive() + if (isBackgrounded) { + notifications.postCompletion("Error creating media", errorMsg, isError = true) + } + } + } + } + } +} 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..0cae00d --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/FlashNotificationController.kt @@ -0,0 +1,189 @@ +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 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) { + + 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 list of compact variants. + private val funWords = listOf("Flashing...", "Fluxabilating...", "Gone fishing...", "Crunching bytes...", "Reticulating splines...", "Writing magic...", "Doing the heavy lifting...") + private val shortChipTexts = listOf("Working", "Writing", "Wait...", "Almost", "Hold on", "Steady", "Busy") + + 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, + "Flash Progress", + 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, "Preparing to flash", "Initializing...", "Started") + 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, isErasing: Boolean) { + val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() + val title = if (isErasing) "Erasing media..." else funWords[wordIndex] + val chipText = if (isErasing) "Erasing" else shortChipTexts[wordIndex] + val text = String.format(Locale.US, "%.1f%% 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/UsbFlasher.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt index 9898cec..e0d5b02 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 @@ -33,6 +33,14 @@ import java.util.zip.ZipInputStream */ class UsbFlasher(private val usbManager: UsbManager, private val context: Context) { + companion object { + /** + * Sentinel returned by [flashImageToUsb] when the write stopped because + * [cancel] was called, as opposed to a user-facing error message. + */ + const val RESULT_CANCELLED = "Cancelled" + } + @Volatile private var isCancelled = false @@ -210,7 +218,7 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex var bytesRead = dataStream!!.read(readBuffer) while (bytesRead != -1) { if (isCancelled) { - return@withContext "Cancelled" + return@withContext RESULT_CANCELLED } // Append read bytes to chunkBuffer From a6dd30a26cd2695dcf8915ef3e0f7d6237e883f7 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:45:18 -0700 Subject: [PATCH 05/10] chore: move hardcoded UI strings to resources Roughly half the user-facing copy lived in strings.xml (the flasher's step and error messages); the other half was hardcoded in composables, the notification builders, and the flash state machine. Move all of it to resources so the copy is localizable and reviewable in one place: - Screen copy for Welcome, Identify, SelectModel, SelectChannel, SelectDrive, Erase, and Flash. - Notification titles, channel name, and the rotating fun-words/status-chip pairs (now index-aligned s). - Shared actions (Continue, OK, Back to Home) deduplicated. Two deliberate non-changes, noted for review: the copy is byte-for-byte identical (including the existing mix of ASCII '...' and typographic ellipsis, worth a separate copy pass), and the notification percentage keeps its explicit Locale.US formatting rather than picking up the resource locale, to avoid a behavior change in this PR. --- .../chrome/recovery/ui/screens/EraseScreen.kt | 12 +- .../chrome/recovery/ui/screens/FlashScreen.kt | 14 ++- .../recovery/ui/screens/FlashViewModel.kt | 24 ++-- .../recovery/ui/screens/IdentifyScreen.kt | 31 +++--- .../ui/screens/SelectChannelScreen.kt | 10 +- .../recovery/ui/screens/SelectDriveScreen.kt | 28 ++--- .../recovery/ui/screens/SelectModelScreen.kt | 12 +- .../recovery/ui/screens/WelcomeScreen.kt | 7 +- .../usb/FlashNotificationController.kt | 22 ++-- android/app/src/main/res/values/strings.xml | 103 +++++++++++++++++- 10 files changed, 195 insertions(+), 68 deletions(-) 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..8bd1890 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 @@ -4,15 +4,19 @@ import android.hardware.usb.UsbDevice import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 kotlinx.coroutines.delay @Composable fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { - var currentStep by remember { mutableStateOf("Erasing...") } + val context = LocalContext.current + var currentStep by remember { mutableStateOf(context.getString(R.string.erase_step)) } var progress by remember { mutableStateOf(0f) } var isFinished by remember { mutableStateOf(false) } @@ -23,7 +27,7 @@ fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { delay(30) // takes about 3 seconds } - currentStep = "Success! Your recovery media has been erased." + currentStep = context.getString(R.string.erase_success) progress = 1f isFinished = true } @@ -52,13 +56,13 @@ fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { ) 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/FlashScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashScreen.kt index 11eedc5..98ab185 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner 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 @@ -26,6 +27,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.chrome.recovery.R /** * FlashScreen renders the progress and outcome of the flash flow. @@ -116,14 +118,14 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF if (!uiState.hasError) { Icon( imageVector = Icons.Filled.CheckCircle, - contentDescription = "Success", + contentDescription = stringResource(R.string.flash_success_icon), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(64.dp).padding(bottom = 16.dp) ) } else { Icon( imageVector = Icons.Filled.Warning, - contentDescription = "Error", + contentDescription = stringResource(R.string.flash_error_icon), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(64.dp).padding(bottom = 16.dp) ) @@ -143,13 +145,13 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF modifier = Modifier.fillMaxWidth().height(8.dp) ) Text( - text = "${(uiState.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 = { viewModel.cancelFlashAndReset() }) { - Text("Cancel and reset USB Stick") + Text(stringResource(R.string.flash_cancel_and_reset)) } } else if (uiState.isErasing && !uiState.isFinished) { LinearProgressIndicator( @@ -157,13 +159,13 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF modifier = Modifier.fillMaxWidth().height(8.dp) ) Text( - text = "${(uiState.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") + 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 index 05d0b47..9703e27 100644 --- 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 @@ -6,6 +6,7 @@ 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.UsbFlasher import kotlinx.coroutines.delay @@ -25,7 +26,7 @@ import kotlinx.coroutines.launch * @param hasError True if the terminal state was an error. */ data class FlashUiState( - val stepText: String = "Starting...", + val stepText: String = "", val progress: Float = 0f, val isErasing: Boolean = false, val isFinished: Boolean = false, @@ -64,7 +65,9 @@ class FlashViewModel( private val flasher = UsbFlasher(usbManager, application) private val notifications = FlashNotificationController(application) - private val _uiState = MutableStateFlow(FlashUiState(isErasing = eraseFirst)) + private val _uiState = MutableStateFlow( + FlashUiState(stepText = application.getString(R.string.flash_step_starting), isErasing = eraseFirst) + ) val uiState: StateFlow = _uiState.asStateFlow() private var started = false @@ -105,7 +108,7 @@ class FlashViewModel( fun cancelFlashAndReset() { flasher.cancel() isCancelledReset = true - _uiState.update { it.copy(stepText = "Erasing media...", progress = 0f, isErasing = true) } + _uiState.update { it.copy(stepText = string(R.string.flash_step_erasing), progress = 0f, isErasing = true) } simulateErase() } @@ -132,7 +135,7 @@ class FlashViewModel( */ private fun simulateErase() { viewModelScope.launch { - _uiState.update { it.copy(stepText = "Erasing media...", isErasing = true) } + _uiState.update { it.copy(stepText = string(R.string.flash_step_erasing), isErasing = true) } for (i in 1..100) { val p = i / 100f _uiState.update { it.copy(progress = p) } @@ -141,7 +144,7 @@ class FlashViewModel( } if (isCancelledReset) { _uiState.update { - it.copy(stepText = "Success! Your USB has been reset.", progress = 1f, isFinished = true, hasError = false) + it.copy(stepText = string(R.string.flash_reset_success), progress = 1f, isFinished = true, hasError = false) } } else { _uiState.update { it.copy(isErasing = false) } @@ -150,6 +153,9 @@ class FlashViewModel( } } + private fun string(resId: Int, vararg args: Any): String = + getApplication().getString(resId, *args) + private fun flash() { viewModelScope.launch { val errorMsg = flasher.flashImageToUsb( @@ -168,18 +174,18 @@ class FlashViewModel( notifications.stopKeepAlive() } errorMsg == null -> { - val message = "Success! Your recovery media is ready." + val message = string(R.string.flash_success) _uiState.update { it.copy(stepText = message, progress = 1f, isFinished = true) } notifications.stopKeepAlive() if (isBackgrounded) { - notifications.postCompletion("Recovery Media Ready", message, isError = false) + notifications.postCompletion(string(R.string.notif_success_title), message, isError = false) } } else -> { - _uiState.update { it.copy(stepText = "Error: $errorMsg", isFinished = true, hasError = true) } + _uiState.update { it.copy(stepText = string(R.string.flash_error, errorMsg), isFinished = true, hasError = true) } notifications.stopKeepAlive() if (isBackgrounded) { - notifications.postCompletion("Error creating media", errorMsg, isError = true) + notifications.postCompletion(string(R.string.notif_error_title), errorMsg, 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..9635925 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 @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale 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 @@ -54,17 +55,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)) } } ) @@ -77,10 +78,10 @@ fun IdentifyScreen( .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 +90,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 ) @@ -122,16 +123,16 @@ fun IdentifyScreen( 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, 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 +142,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 @@ -162,14 +163,14 @@ fun IdentifyScreen( 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 +178,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 +187,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..1f8cdab 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,7 +4,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +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 @@ -38,19 +40,19 @@ fun SelectChannelScreen(modelName: String, onNext: (String) -> Unit) { } Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text("Select a channel", style = MaterialTheme.typography.headlineMedium) + 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() } 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) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) 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 434fd50..8106307 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 @@ -20,9 +20,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 @Composable fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, onEraseFirst: ((UsbDevice) -> Unit)? = null) { @@ -57,7 +59,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } } else { Log.d("USB", "permission denied for device $device") - permissionError = "Permission denied for USB device." + permissionError = context.getString(R.string.permission_denied_usb) pendingAction = null } } @@ -96,15 +98,15 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Insert your USB flash drive or SD card", style = MaterialTheme.typography.titleLarge) + 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)) { @@ -122,8 +124,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) } } } @@ -144,7 +146,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, }, enabled = selectedDevice != null ) { - Text("Continue") + Text(stringResource(R.string.action_continue)) } } } @@ -152,8 +154,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 @@ -163,7 +165,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } } }) { - Text("Yes, erase first") + Text(stringResource(R.string.erase_prompt_confirm)) } }, dismissButton = { @@ -171,7 +173,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)) } } ) @@ -180,11 +182,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 88979f2..9b2b374 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 @@ -7,8 +7,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties +import com.google.chrome.recovery.R import com.google.chrome.recovery.data.RecoveryImage import com.google.chrome.recovery.data.RecoveryRepository @@ -66,9 +68,9 @@ fun SelectModelScreen(onNext: (String) -> Unit) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Identify your Book", style = MaterialTheme.typography.titleLarge) + 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) { @@ -90,7 +92,7 @@ fun SelectModelScreen(onNext: (String) -> Unit) { 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() @@ -134,7 +136,7 @@ fun SelectModelScreen(onNext: (String) -> Unit) { 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) }, @@ -168,7 +170,7 @@ fun SelectModelScreen(onNext: (String) -> Unit) { onClick = { selectedModelName?.let { 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/WelcomeScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/WelcomeScreen.kt index 7ef0b83..1bbf87f 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 @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 @@ -30,20 +31,20 @@ 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") + Text(stringResource(R.string.welcome_get_started)) } } } 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 index 0cae00d..f94703a 100644 --- 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 @@ -15,6 +15,7 @@ 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 /** @@ -52,9 +53,9 @@ class FlashNotificationController(private val context: Context) { context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Rotating titles shown while flashing. The status-bar chip only fits a short - // word, hence the parallel list of compact variants. - private val funWords = listOf("Flashing...", "Fluxabilating...", "Gone fishing...", "Crunching bytes...", "Reticulating splines...", "Writing magic...", "Doing the heavy lifting...") - private val shortChipTexts = listOf("Working", "Writing", "Wait...", "Almost", "Hold on", "Steady", "Busy") + // 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 { @@ -70,7 +71,7 @@ class FlashNotificationController(private val context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - "Flash Progress", + context.getString(R.string.notif_channel_name), NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(channel) @@ -83,7 +84,12 @@ class FlashNotificationController(private val context: Context) { * minimizes during an erase-first pass. */ fun startKeepAlive() { - val initialNotif = buildProgressNotification(0f, "Preparing to flash", "Initializing...", "Started") + 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) @@ -109,9 +115,9 @@ class FlashNotificationController(private val context: Context) { */ fun postProgress(progress: Float, isErasing: Boolean) { val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() - val title = if (isErasing) "Erasing media..." else funWords[wordIndex] - val chipText = if (isErasing) "Erasing" else shortChipTexts[wordIndex] - val text = String.format(Locale.US, "%.1f%% complete", progress * 100) + val title = if (isErasing) context.getString(R.string.notif_erasing_title) else funWords[wordIndex] + val chipText = if (isErasing) context.getString(R.string.notif_erasing_chip) else 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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5261bcc..ccf9dc2 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). @@ -21,10 +24,108 @@ Verification failed. The data written to the USB drive could not be read back correctly. 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 + + + 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 format and erase the drive before proceeding with the recovery image? This can help 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. + Please do not remove your recovery media. + + + Starting... + Erasing media... + Success! Your USB has been reset. + Success! Your recovery media is ready. + 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 + 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 + From aedb4951f0366165a5f8fcd523d895efa4205d37 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:47:04 -0700 Subject: [PATCH 06/10] chore: remove leftover debug logging and fix compiler warnings - Drop the Log.d progress line that fired every 500ms during the entire write, and the Log.d in SelectDriveScreen's permission-denied path (the denial already surfaces to the user via the error dialog). Log.i/Log.e diagnostics stay. - Fix the remaining Kotlin compiler warnings: deprecated LinearProgressIndicator overload in EraseScreen, a redundant displayName initializer, and an unnecessary non-null assertion. The build is now warning-free except for EraseScreen's unused 'device' parameter, which is kept deliberately: the erase flow is currently simulated and a follow-up will make it operate on the real device. --- .../com/google/chrome/recovery/ui/screens/EraseScreen.kt | 2 +- .../google/chrome/recovery/ui/screens/SelectDriveScreen.kt | 2 -- .../main/java/com/google/chrome/recovery/usb/UsbFlasher.kt | 6 ++---- 3 files changed, 3 insertions(+), 7 deletions(-) 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 8bd1890..6ecd1ed 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 @@ -46,7 +46,7 @@ fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { if (!isFinished) { LinearProgressIndicator( - progress = progress, + progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp) ) Text( 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 8106307..c149967 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 @@ -8,7 +8,6 @@ import android.content.IntentFilter import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.os.Build -import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -58,7 +57,6 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, pendingAction = null } } else { - Log.d("USB", "permission denied for device $device") permissionError = context.getString(R.string.permission_denied_usb) pendingAction = null } 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 e0d5b02..05a39bb 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 @@ -61,7 +61,6 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex try { var isZip = url.endsWith(".zip", ignoreCase = true) - var displayName = "" val inputStream: InputStream val contentLength: Long @@ -76,7 +75,7 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex if (sizeIndex != -1) size = it.getLong(sizeIndex) 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 } } @@ -250,11 +249,10 @@ class UsbFlasher(private val usbManager: UsbManager, private val context: Contex 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 From 75616c193382cd12fab528fa9659b4bce3bb6c44 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:47:48 -0700 Subject: [PATCH 07/10] chore: add .editorconfig matching the Kotlin official style gradle.properties already declares kotlin.code.style=official; this makes editors and IDEs enforce the same conventions (4-space indent, UTF-8, LF, final newline) without depending on per-machine IDE settings. --- .editorconfig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .editorconfig 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 From b483f0c8549f7ee190f97ba15823f5944f3c1b50 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 10:47:48 -0700 Subject: [PATCH 08/10] ci: build and lint pull requests with GitHub Actions Runs assembleDebug and Android lint on every PR and on pushes to main, uploading the lint HTML report as an artifact when the build fails. Both tasks pass clean on the current tree. No secrets required. --- .github/workflows/android.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/android.yml 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 From 57cd27694d9eec53a253ef540b57b19e64900fa4 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 11:25:00 -0700 Subject: [PATCH 09/10] fix: count the channel screen as wizard step 2 select_channel was never included in the top bar's step mapping, so the channel screen fell through to the else branch and displayed 'Step 1 of 4'. Model identification and channel choice are both part of picking the image, so it joins identify/select_model as step 2. --- .../src/main/java/com/google/chrome/recovery/ui/RecoveryApp.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5686d3f..7a4cc53 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 @@ -58,7 +58,7 @@ fun RecoveryApp() { title = { val stepIndex = when (currentRoute) { Route.Welcome.pattern -> 1 - Route.Identify.pattern, Route.SelectModel.pattern -> 2 + 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 From a189aa5db4ffb8dcdb58c7d7ad9ab2306d4a6e90 Mon Sep 17 00:00:00 2001 From: Jesse Johnston Date: Fri, 10 Jul 2026 11:39:11 -0700 Subject: [PATCH 10/10] chore: extract the percent label in EraseScreen missed by the strings pass --- .../java/com/google/chrome/recovery/ui/screens/EraseScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6ecd1ed..3057051 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 @@ -50,7 +50,7 @@ fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { modifier = Modifier.fillMaxWidth().height(8.dp) ) Text( - text = "${(progress * 100).toInt()}%", + text = stringResource(R.string.flash_progress_percent, (progress * 100).toInt()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) )