diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f6cf0e9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# Kotlin official code style (matches kotlin.code.style=official in gradle.properties) +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +max_line_length = 120 + +[*.{xml,yml,yaml}] +indent_size = 4 +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..d5d9d0d --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,28 @@ +name: Android CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 + - name: Build and lint + run: ./gradlew assembleDebug lint + - name: Upload lint report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: android/app/build/reports/lint-results-debug.html diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bfa1f87..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/RecoveryApp.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/RecoveryApp.kt index 9c96bf8..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 @@ -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,15 +40,15 @@ 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() - navController.navigate("select_drive") + navController.navigate(Route.SelectDrive.pattern) } } @@ -53,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, Route.SelectChannel.pattern -> 2 + Route.SelectDrive.pattern, Route.EraseDrive.pattern -> 3 + Route.Flash.pattern, Route.EraseFlash.pattern -> 4 else -> 1 } Column { Text(stringResource(R.string.app_name)) - if (currentRoute != "welcome" && currentRoute != null) { + if (currentRoute != Route.Welcome.pattern && currentRoute != null) { Text( stringResource(R.string.title_step_of, stepIndex, 4), style = MaterialTheme.typography.labelMedium, @@ -77,9 +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 { @@ -102,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) } ) } @@ -112,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/${android.net.Uri.encode(modelName)}") + navController.navigate(Route.SelectChannel.forModel(modelName)) }, onSelectFromList = { - navController.navigate("select_model") + navController.navigate(Route.SelectModel.pattern) }, onSelectLocalImage = { filePickerLauncher.launch(arrayOf("*/*")) } ) } - composable("select_model") { + composable(Route.SelectModel.pattern) { SelectModelScreen(onNext = { modelName -> - navController.navigate("select_channel/${android.net.Uri.encode(modelName)}") + navController.navigate(Route.SelectChannel.forModel(modelName)) }) } - composable("select_channel/{modelName}") { backStackEntry -> - val modelName = android.net.Uri.decode(backStackEntry.arguments?.getString("modelName") ?: "") + composable(Route.SelectChannel.pattern) { backStackEntry -> + val modelName = Uri.decode(backStackEntry.arguments?.getString(Route.ARG_MODEL_NAME) ?: "") SelectChannelScreen( modelName = modelName, onNext = { modelUrl -> selectedUrl = modelUrl - navController.navigate("select_drive") + 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" + } +} 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..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 @@ -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 } @@ -42,23 +46,23 @@ fun EraseScreen(device: UsbDevice, onFinish: () -> Unit) { if (!isFinished) { LinearProgressIndicator( - progress = progress, + progress = { progress }, 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) ) 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 4febd26..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 @@ -1,5 +1,12 @@ package com.google.chrome.recovery.ui.screens +import android.Manifest +import android.content.pm.PackageManager +import android.hardware.usb.UsbDevice +import android.os.Build +import android.view.ViewTreeObserver +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -8,53 +15,73 @@ 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 com.google.chrome.recovery.usb.UsbFlasher +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 +import androidx.lifecycle.Lifecycle +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 handles the core execution logic of the Book Recovery utility. - * - * This screen is responsible for: - * 1. Tracking and displaying the status of writing the image to the USB device. - * 2. Simulating a device erasure if [eraseFirst] is selected. - * 3. Safely spinning up the [KeepAliveService] (Foreground Service) the moment the screen - * is composed. This elevates the app to a foreground priority, ensuring the OS doesn't - * kill the background write process if the user navigates to their home screen. - * 4. Generating and updating an Android 16 (API 36) Live Update status bar chip using - * `Notification.ProgressStyle()` to keep the user informed natively. + * FlashScreen renders the progress and outcome of the flash flow. + * + * All orchestration — the erase/flash/success/error state machine, the + * [com.google.chrome.recovery.usb.UsbFlasher] lifecycle, the KeepAlive Foreground + * Service, and the Android 16 (API 36) Live Update notification — lives in + * [FlashViewModel]. The screen keeps only the two responsibilities that need the + * view layer: + * 1. Requesting the POST_NOTIFICATIONS runtime permission (needs an Activity). + * 2. Tracking lifecycle state and window focus (needs a View) so the ViewModel + * knows when the user is looking at the app versus relying on the notification. * * @param url The local or remote URL pointing to the recovery image .zip file. * @param device The physical USB device the user has authorized writing to. - * @param eraseFirst If true, the screen will execute a 3-second simulation block before flashing. + * @param eraseFirst If true, a 3-second erase simulation runs before flashing. * @param onFinish Callback invoked when the user dismisses the success/error summary. */ @Composable fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onFinish: () -> Unit) { - var currentStep by remember { mutableStateOf("Starting...") } - var progress by remember { mutableStateOf(0f) } - var isFinished by remember { mutableStateOf(false) } - var hasError by remember { mutableStateOf(false) } - - var isErasing by remember { mutableStateOf(eraseFirst) } - var isCancelledErase by remember { mutableStateOf(false) } - var isFlashing by remember { mutableStateOf(!eraseFirst) } + 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 = androidx.compose.ui.platform.LocalLifecycleOwner.current - val view = androidx.compose.ui.platform.LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val view = LocalView.current var isWindowFocused by remember { mutableStateOf(view.hasWindowFocus()) } DisposableEffect(view) { - val listener = android.view.ViewTreeObserver.OnWindowFocusChangeListener { hasFocus -> + val listener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus -> isWindowFocused = hasFocus } view.viewTreeObserver.addOnWindowFocusChangeListener(listener) @@ -63,18 +90,11 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val isBackgrounded = isLifecycleBackgrounded || !isWindowFocused - val isBackgroundedState = androidx.compose.runtime.rememberUpdatedState(isBackgrounded) - - val permissionLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.RequestPermission() - ) {} - DisposableEffect(lifecycleOwner) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_PAUSE || event == androidx.lifecycle.Lifecycle.Event.ON_STOP) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { isLifecycleBackgrounded = true - } else if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME || event == androidx.lifecycle.Lifecycle.Event.ON_START) { + } else if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) { isLifecycleBackgrounded = false } } @@ -84,204 +104,9 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF } } - val notificationManager = remember { context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager } - - val pendingIntent = remember(context) { - val intent = android.content.Intent(context, com.google.chrome.recovery.MainActivity::class.java).apply { - flags = android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP - } - android.app.PendingIntent.getActivity( - context, 0, intent, android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE - ) - } - - val buildNotification: (Float, String, String, String) -> android.app.Notification = remember(context, pendingIntent) { - { p, title, text, chipText -> - if (android.os.Build.VERSION.SDK_INT >= 36) { - val nativeBuilder = android.app.Notification.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle(title) - .setContentText(text) - .setColor(android.graphics.Color.parseColor("#4285F4")) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(android.app.Notification.CATEGORY_PROGRESS) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setProgress(100, (p * 100).toInt(), false) - - try { - nativeBuilder.setShortCriticalText(chipText) - val progressStyle = android.app.Notification.ProgressStyle() - progressStyle.setProgress((p * 100).toInt()) - nativeBuilder.setStyle(progressStyle) - - val extras = android.os.Bundle() - extras.putBoolean("android.requestPromotedOngoing", true) - nativeBuilder.addExtras(extras) - } catch (e: Exception) { - android.util.Log.e("FlashScreen", "Error setting Live Update styles", e) - } - - val built = nativeBuilder.build() - built.flags = built.flags or 262144 - built - } else { - androidx.core.app.NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle(title) - .setContentText(text) - .setColor(android.graphics.Color.parseColor("#4285F4")) - .setProgress(1000, (p * 1000).toInt(), false) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(androidx.core.app.NotificationCompat.CATEGORY_PROGRESS) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build() - } - } - } - - val funWords = remember { listOf("Flashing...", "Fluxabilating...", "Gone fishing...", "Crunching bytes...", "Reticulating splines...", "Writing magic...", "Doing the heavy lifting...") } - val shortChipTexts = remember { listOf("Working", "Writing", "Wait...", "Almost", "Hold on", "Steady", "Busy") } - - LaunchedEffect(Unit) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) { - permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) - } - } - - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - val channel = android.app.NotificationChannel( - "flash_progress", - "Flash Progress", - android.app.NotificationManager.IMPORTANCE_DEFAULT - ) - notificationManager.createNotificationChannel(channel) - } - - // Start Foreground Service gracefully once at the beginning to claim foreground priority - // This prevents ForegroundServiceStartNotAllowedException if the user minimizes during eraseFirst - val initialNotif = buildNotification(0f, "Preparing to flash", "Initializing...", "Started") - com.google.chrome.recovery.usb.KeepAliveService.currentNotification = initialNotif - - val serviceIntent = android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java) - try { - if (android.os.Build.VERSION.SDK_INT >= 26) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - } catch (e: Exception) { - android.util.Log.e("FlashScreen", "Could not start KeepAliveService", e) - } - } - + val isBackgrounded = isLifecycleBackgrounded || !isWindowFocused LaunchedEffect(isBackgrounded) { - if (!isBackgrounded) { - notificationManager.cancel(1001) - } else if ((isFlashing || isErasing) && !isFinished && !hasError) { - // Post an immediate notification when focus is lost - val p = progress - val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() - val currentFunWord = if (isErasing) "Erasing media..." else funWords[wordIndex] - val chipText = if (isErasing) "Erasing" else shortChipTexts[wordIndex] - - val notif = buildNotification(p, currentFunWord, String.format(java.util.Locale.US, "%.1f%% complete", p * 100), chipText) - - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - } - } - - LaunchedEffect(isErasing) { - if (isErasing) { - currentStep = "Erasing media..." - for (i in 1..100) { - progress = i / 100f - val notif = buildNotification(progress, "Erasing media...", String.format(java.util.Locale.US, "%.1f%% complete", progress * 100), "Erasing") - com.google.chrome.recovery.usb.KeepAliveService.currentNotification = notif - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - delay(30) - } - if (isCancelledErase) { - currentStep = "Success! Your USB has been reset." - progress = 1f - isFinished = true - hasError = false - } else { - isErasing = false - isFlashing = true - } - } - } - - LaunchedEffect(isFlashing) { - if (!isFlashing) return@LaunchedEffect - - val errorMsg = flasher.flashImageToUsb( - device = device, - url = url, - onStep = { step -> currentStep = step }, - onProgress = { p -> - progress = p - val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() - val currentFunWord = funWords[wordIndex] - val chipText = shortChipTexts[wordIndex] - - val text = String.format(java.util.Locale.US, "%.1f%% complete", p * 100) - val notif = buildNotification(p, currentFunWord, text, chipText) - - com.google.chrome.recovery.usb.KeepAliveService.currentNotification = notif - - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, notif) - } - } - ) - - if (errorMsg == "Cancelled") { - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) - } else if (errorMsg == null) { - currentStep = "Success! Your recovery media is ready." - progress = 1f - isFinished = true - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) - if (isBackgroundedState.value) { - val builder = androidx.core.app.NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentTitle("Recovery Media Ready") - .setContentText("Success! Your recovery media is ready.") - .setOngoing(false) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, builder.build()) - } - } - } else { - currentStep = "Error: $errorMsg" - hasError = true - isFinished = true - context.stopService(android.content.Intent(context, com.google.chrome.recovery.usb.KeepAliveService::class.java)) - if (isBackgroundedState.value) { - val builder = androidx.core.app.NotificationCompat.Builder(context, "flash_progress") - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Error creating media") - .setContentText(errorMsg) - .setOngoing(false) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - if (androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(1001, builder.build()) - } - } - } + viewModel.onBackgroundedChanged(isBackgrounded) } Column( @@ -289,64 +114,58 @@ fun FlashScreen(url: String, device: UsbDevice, eraseFirst: Boolean = false, onF horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - if (isFinished) { - if (!hasError) { - androidx.compose.material3.Icon( + if (uiState.isFinished) { + if (!uiState.hasError) { + 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) + contentDescription = stringResource(R.string.flash_success_icon), + 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) + contentDescription = stringResource(R.string.flash_error_icon), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(64.dp).padding(bottom = 16.dp) ) } } - androidx.compose.material3.Text( - text = currentStep, - style = androidx.compose.material3.MaterialTheme.typography.titleLarge, - modifier = androidx.compose.ui.Modifier.padding(bottom = 32.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + Text( + text = uiState.stepText, + style = 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 = stringResource(R.string.flash_progress_percent, (uiState.progress * 100).toInt()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) Spacer(modifier = Modifier.height(24.dp)) - OutlinedButton(onClick = { - flasher.cancel() - currentStep = "Erasing media..." - progress = 0f - isCancelledErase = true - isErasing = true - }) { - Text("Cancel and reset USB Stick") + OutlinedButton(onClick = { viewModel.cancelFlashAndReset() }) { + Text(stringResource(R.string.flash_cancel_and_reset)) } - } 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 = 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 new file mode 100644 index 0000000..9703e27 --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/FlashViewModel.kt @@ -0,0 +1,194 @@ +package com.google.chrome.recovery.ui.screens + +import android.app.Application +import android.content.Context +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.chrome.recovery.R +import com.google.chrome.recovery.usb.FlashNotificationController +import com.google.chrome.recovery.usb.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 = "", + 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(stepText = application.getString(R.string.flash_step_starting), isErasing = eraseFirst) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var started = false + private var isCancelledReset = false + + /** + * Tracks whether the app is currently backgrounded (reported by the screen), + * so terminal notifications are only posted when the user isn't looking. + * Volatile because the flash pipeline reads it from a background dispatcher. + */ + @Volatile + private var isBackgrounded = false + + /** + * Kicks off the flow. Idempotent: recomposition, rotation, or returning to the + * screen must not restart a running flash. + */ + fun start() { + if (started) return + started = true + + notifications.createChannel() + // Claim foreground priority once at the beginning. This prevents + // ForegroundServiceStartNotAllowedException if the user minimizes during eraseFirst. + notifications.startKeepAlive() + + if (eraseFirst) { + 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 = string(R.string.flash_step_erasing), 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 = string(R.string.flash_step_erasing), 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 = string(R.string.flash_reset_success), progress = 1f, isFinished = true, hasError = false) + } + } else { + _uiState.update { it.copy(isErasing = false) } + flash() + } + } + } + + private fun string(resId: Int, vararg args: Any): String = + getApplication().getString(resId, *args) + + 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 = string(R.string.flash_success) + _uiState.update { it.copy(stepText = message, progress = 1f, isFinished = true) } + notifications.stopKeepAlive() + if (isBackgrounded) { + notifications.postCompletion(string(R.string.notif_success_title), message, isError = false) + } + } + else -> { + _uiState.update { it.copy(stepText = string(R.string.flash_error, errorMsg), isFinished = true, hasError = true) } + notifications.stopKeepAlive() + if (isBackgrounded) { + 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 63478f8..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 @@ -1,8 +1,13 @@ package com.google.chrome.recovery.ui.screens +import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager +import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -14,8 +19,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) { @@ -33,11 +41,11 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, var permissionError by remember { mutableStateOf(null) } DisposableEffect(context) { - val receiver = object : android.content.BroadcastReceiver() { - override fun onReceive(context: Context, intent: android.content.Intent) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { if (ACTION_USB_PERMISSION == intent.action) { synchronized(this) { - val device: UsbDevice? = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + val device: UsbDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) } else { @Suppress("DEPRECATION") @@ -49,20 +57,19 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, pendingAction = null } } else { - android.util.Log.d("USB", "permission denied for device $device") - permissionError = "Permission denied for USB device." + permissionError = context.getString(R.string.permission_denied_usb) pendingAction = null } } } } } - val filter = android.content.IntentFilter(ACTION_USB_PERMISSION) - androidx.core.content.ContextCompat.registerReceiver( - context, - receiver, - filter, - androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED + val filter = IntentFilter(ACTION_USB_PERMISSION) + ContextCompat.registerReceiver( + context, + receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED ) onDispose { context.unregisterReceiver(receiver) @@ -75,29 +82,29 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } else { pendingAction = action permissionError = null - val permissionIntent = android.app.PendingIntent.getBroadcast( - context, - 0, - android.content.Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) - android.app.PendingIntent.FLAG_MUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT - else - android.app.PendingIntent.FLAG_UPDATE_CURRENT + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else + PendingIntent.FLAG_UPDATE_CURRENT ) usbManager.requestPermission(device, permissionIntent) } } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Insert your USB flash drive or SD card", style = MaterialTheme.typography.titleLarge) + 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)) { @@ -115,8 +122,8 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, Icon(Icons.Filled.Build, contentDescription = null, modifier = Modifier.size(32.dp)) Spacer(modifier = Modifier.width(16.dp)) Column { - Text(device.productName ?: "Unknown USB Device", fontWeight = FontWeight.Medium) - Text("Manufacturer: ${device.manufacturerName ?: "Unknown"}", style = MaterialTheme.typography.bodySmall) + Text(device.productName ?: stringResource(R.string.select_drive_unknown_device), fontWeight = FontWeight.Medium) + Text(stringResource(R.string.select_drive_manufacturer, device.manufacturerName ?: stringResource(R.string.select_drive_unknown_manufacturer)), style = MaterialTheme.typography.bodySmall) } } } @@ -137,7 +144,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, }, enabled = selectedDevice != null ) { - Text("Continue") + Text(stringResource(R.string.action_continue)) } } } @@ -145,8 +152,8 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, if (showErasePrompt) { AlertDialog( onDismissRequest = { showErasePrompt = false }, - title = { Text("Erase drive before proceeding?") }, - text = { Text("Do you want to format and erase the drive before proceeding with the recovery image? This can help resolve issues with previously used media.") }, + title = { Text(stringResource(R.string.erase_prompt_title)) }, + text = { Text(stringResource(R.string.erase_prompt_body)) }, confirmButton = { Button(onClick = { showErasePrompt = false @@ -156,7 +163,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, } } }) { - Text("Yes, erase first") + Text(stringResource(R.string.erase_prompt_confirm)) } }, dismissButton = { @@ -164,7 +171,7 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, showErasePrompt = false selectedDevice?.let { dev -> handleDeviceSelection(dev) { onNext(dev) } } }) { - Text("Skip and proceed") + Text(stringResource(R.string.erase_prompt_skip)) } } ) @@ -173,11 +180,11 @@ fun SelectDriveScreen(isEraseFlow: Boolean = false, onNext: (UsbDevice) -> Unit, if (permissionError != null) { AlertDialog( onDismissRequest = { permissionError = null }, - title = { Text("Permission Required") }, + title = { Text(stringResource(R.string.permission_required_title)) }, text = { Text(permissionError ?: "") }, confirmButton = { Button(onClick = { permissionError = null }) { - Text("OK") + Text(stringResource(R.string.action_ok)) } } ) diff --git a/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectModelScreen.kt b/android/app/src/main/java/com/google/chrome/recovery/ui/screens/SelectModelScreen.kt index ef11563..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,7 +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 @@ -65,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) { @@ -89,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() @@ -98,7 +101,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 -> @@ -133,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) }, @@ -143,7 +146,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 -> @@ -167,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 new file mode 100644 index 0000000..f94703a --- /dev/null +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/FlashNotificationController.kt @@ -0,0 +1,195 @@ +package com.google.chrome.recovery.usb + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.google.chrome.recovery.MainActivity +import com.google.chrome.recovery.R +import java.util.Locale + +/** + * Owns every notification and foreground-service concern of the flash flow. + * + * The flashing pipeline needs three pieces of system plumbing to survive and stay + * visible while the user is away from the app: + * 1. The "flash_progress" notification channel. + * 2. The [KeepAliveService] Foreground Service, which claims foreground priority so the + * OS doesn't kill the multi-minute write when the app is backgrounded. + * 3. The progress notification itself — on API 36 (Android 16) an "amber-alert style" + * Live Update built with `Notification.ProgressStyle()` and `setShortCriticalText` + * (projected into the status-bar chip), with a `NotificationCompat` fallback for + * older releases. + * + * Keeping all of that here means [com.google.chrome.recovery.ui.screens.FlashViewModel] + * only decides *when* to notify, never *how*. + */ +class FlashNotificationController(private val context: Context) { + + companion object { + private const val TAG = "FlashNotifications" + private const val CHANNEL_ID = "flash_progress" + private const val NOTIFICATION_ID = 1001 + + /** + * `Notification.FLAG_PROMOTED_ONGOING` (1 shl 18). Requests promotion to a + * Live Update on API 36. The constant is not exposed through the public SDK + * stubs we compile against, so the raw value is used. + */ + private const val FLAG_PROMOTED_ONGOING = 262144 + } + + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Rotating titles shown while flashing. The status-bar chip only fits a short + // word, hence the parallel array of compact variants. + private val funWords = context.resources.getStringArray(R.array.flash_fun_words) + private val shortChipTexts = context.resources.getStringArray(R.array.flash_fun_chips) + + private val pendingIntent: PendingIntent by lazy { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + PendingIntent.getActivity( + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** Creates the progress notification channel. Safe to call repeatedly. */ + fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notif_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Starts [KeepAliveService] with an initial notification so the app holds + * foreground priority for the whole flash, even if the user immediately + * minimizes during an erase-first pass. + */ + fun startKeepAlive() { + val initialNotif = buildProgressNotification( + 0f, + context.getString(R.string.notif_preparing_title), + context.getString(R.string.notif_preparing_text), + context.getString(R.string.notif_preparing_chip) + ) + KeepAliveService.currentNotification = initialNotif + + val serviceIntent = Intent(context, KeepAliveService::class.java) + try { + if (Build.VERSION.SDK_INT >= 26) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } catch (e: Exception) { + Log.e(TAG, "Could not start KeepAliveService", e) + } + } + + /** Stops [KeepAliveService] once the flash finishes, fails, or is cancelled. */ + fun stopKeepAlive() { + context.stopService(Intent(context, KeepAliveService::class.java)) + } + + /** + * Publishes the current progress: refreshes the notification the Foreground + * Service is holding and (if the user granted notification permission) posts it. + */ + fun postProgress(progress: Float, isErasing: Boolean) { + val wordIndex = ((System.currentTimeMillis() / 10000) % funWords.size).toInt() + 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 + notifyIfPermitted(notification) + } + + /** Removes the progress notification (the user is looking at the app again). */ + fun dismissProgress() { + notificationManager.cancel(NOTIFICATION_ID) + } + + /** Posts the terminal success/error notification shown when the app is backgrounded. */ + fun postCompletion(title: String, text: String, isError: Boolean) { + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.stat_sys_download_done) + .setContentTitle(title) + .setContentText(text) + .setOngoing(false) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + notifyIfPermitted(builder.build()) + } + + private fun notifyIfPermitted(notification: Notification) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + notificationManager.notify(NOTIFICATION_ID, notification) + } + } + + private fun buildProgressNotification(progress: Float, title: String, text: String, chipText: String): Notification { + return if (Build.VERSION.SDK_INT >= 36) { + val nativeBuilder = Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(title) + .setContentText(text) + .setColor(Color.parseColor("#4285F4")) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_PROGRESS) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setProgress(100, (progress * 100).toInt(), false) + + try { + nativeBuilder.setShortCriticalText(chipText) + val progressStyle = Notification.ProgressStyle() + progressStyle.setProgress((progress * 100).toInt()) + nativeBuilder.setStyle(progressStyle) + + val extras = Bundle() + extras.putBoolean("android.requestPromotedOngoing", true) + nativeBuilder.addExtras(extras) + } catch (e: Exception) { + Log.e(TAG, "Error setting Live Update styles", e) + } + + val built = nativeBuilder.build() + built.flags = built.flags or FLAG_PROMOTED_ONGOING + built + } else { + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(title) + .setContentText(text) + .setColor(Color.parseColor("#4285F4")) + .setProgress(1000, (progress * 1000).toInt(), false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + } + } +} diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt index 317aa52..9dc6c15 100644 --- a/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt +++ b/android/app/src/main/java/com/google/chrome/recovery/usb/KeepAliveService.kt @@ -3,6 +3,8 @@ package com.google.chrome.recovery.usb import android.app.Notification import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder /** @@ -27,9 +29,9 @@ class KeepAliveService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { currentNotification?.let { try { - if (android.os.Build.VERSION.SDK_INT >= 34) { + if (Build.VERSION.SDK_INT >= 34) { // API 34+ requires specifying foregroundServiceType in startForeground - val type = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE startForeground(1001, it, type) } else { startForeground(1001, it) diff --git a/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt b/android/app/src/main/java/com/google/chrome/recovery/usb/UsbFlasher.kt index 0b1ca92..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 @@ -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 @@ -26,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 @@ -40,28 +55,27 @@ 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 { var isZip = url.endsWith(".zip", ignoreCase = true) - var displayName = "" val inputStream: InputStream val contentLength: Long if (url.startsWith("content://")) { onStep(context.getString(R.string.step_opening_local)) - val uri = android.net.Uri.parse(url) + val uri = Uri.parse(url) val cursor = context.contentResolver.query(uri, null, null, null, null) var size = 0L cursor?.use { if (it.moveToFirst()) { - val sizeIndex = it.getColumnIndex(android.provider.OpenableColumns.SIZE) + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) if (sizeIndex != -1) size = it.getLong(sizeIndex) - val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (nameIndex != -1) { - displayName = it.getString(nameIndex) ?: "" + val displayName = it.getString(nameIndex) ?: "" if (displayName.endsWith(".zip", ignoreCase = true)) isZip = true } } @@ -159,18 +173,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 +202,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)) @@ -203,7 +217,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 @@ -235,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 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 + 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%") -}