From 800f0fa15ae32c36b1ec065359cedb13755ed9f2 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 10 Mar 2026 04:21:44 +0500 Subject: [PATCH] new feature: create custom images --- app/src/main/AndroidManifest.xml | 5 + .../isodroid/isodrive/CreateImgEventBus.kt | 37 +++ .../notification/CreateImgReceiver.kt | 19 ++ .../notification/NotificationHelper.kt | 86 ++++++- .../isodroid/ui/components/CreateImgDialog.kt | 234 ++++++++++++++++++ .../sh/sar/isodroid/ui/screens/MainScreen.kt | 33 +++ .../sar/isodroid/viewmodel/MainViewModel.kt | 153 +++++++++++- 7 files changed, 562 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/sh/sar/isodroid/isodrive/CreateImgEventBus.kt create mode 100644 app/src/main/java/sh/sar/isodroid/notification/CreateImgReceiver.kt create mode 100644 app/src/main/java/sh/sar/isodroid/ui/components/CreateImgDialog.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7559476..af1d93d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,6 +42,11 @@ android:name=".notification.UnmountReceiver" android:exported="false" /> + + + diff --git a/app/src/main/java/sh/sar/isodroid/isodrive/CreateImgEventBus.kt b/app/src/main/java/sh/sar/isodroid/isodrive/CreateImgEventBus.kt new file mode 100644 index 0000000..75408cb --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/isodrive/CreateImgEventBus.kt @@ -0,0 +1,37 @@ +package sh.sar.isodroid.isodrive + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +sealed class CreateImgEvent { + data class Progress(val bytesWritten: Long, val totalBytes: Long) : CreateImgEvent() + data class Complete(val success: Boolean, val filePath: String?) : CreateImgEvent() + object Cancelled : CreateImgEvent() +} + +object CreateImgEventBus { + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + private var cancelRequested = false + + suspend fun emitProgress(bytesWritten: Long, totalBytes: Long) { + _events.emit(CreateImgEvent.Progress(bytesWritten, totalBytes)) + } + + suspend fun emitComplete(success: Boolean, filePath: String?) { + cancelRequested = false + _events.emit(CreateImgEvent.Complete(success, filePath)) + } + + suspend fun cancel() { + cancelRequested = true + _events.emit(CreateImgEvent.Cancelled) + } + + fun isCancelRequested(): Boolean = cancelRequested + + fun resetCancel() { + cancelRequested = false + } +} diff --git a/app/src/main/java/sh/sar/isodroid/notification/CreateImgReceiver.kt b/app/src/main/java/sh/sar/isodroid/notification/CreateImgReceiver.kt new file mode 100644 index 0000000..fd6ed16 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/notification/CreateImgReceiver.kt @@ -0,0 +1,19 @@ +package sh.sar.isodroid.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import sh.sar.isodroid.isodrive.CreateImgEventBus + +class CreateImgReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == NotificationHelper.ACTION_CANCEL_CREATE) { + CoroutineScope(Dispatchers.Main).launch { + CreateImgEventBus.cancel() + } + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/notification/NotificationHelper.kt b/app/src/main/java/sh/sar/isodroid/notification/NotificationHelper.kt index cb440e5..4be852c 100644 --- a/app/src/main/java/sh/sar/isodroid/notification/NotificationHelper.kt +++ b/app/src/main/java/sh/sar/isodroid/notification/NotificationHelper.kt @@ -16,8 +16,11 @@ class NotificationHelper(private val context: Context) { companion object { const val CHANNEL_ID = "iso_drive_mount_status" + const val CHANNEL_ID_PROGRESS = "iso_drive_progress" const val NOTIFICATION_ID = 1001 + const val NOTIFICATION_ID_PROGRESS = 1002 const val ACTION_UNMOUNT = "sh.sar.isodroid.ACTION_UNMOUNT" + const val ACTION_CANCEL_CREATE = "sh.sar.isodroid.ACTION_CANCEL_CREATE" @Volatile private var instance: NotificationHelper? = null @@ -32,11 +35,11 @@ class NotificationHelper(private val context: Context) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager init { - createNotificationChannel() + createNotificationChannels() } - private fun createNotificationChannel() { - val channel = NotificationChannel( + private fun createNotificationChannels() { + val mountChannel = NotificationChannel( CHANNEL_ID, "Mount Status", NotificationManager.IMPORTANCE_LOW @@ -44,7 +47,18 @@ class NotificationHelper(private val context: Context) { description = "Shows when an ISO/IMG file is mounted" setShowBadge(false) } - notificationManager.createNotificationChannel(channel) + + val progressChannel = NotificationChannel( + CHANNEL_ID_PROGRESS, + "Progress", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Shows progress for file operations" + setShowBadge(false) + } + + notificationManager.createNotificationChannel(mountChannel) + notificationManager.createNotificationChannel(progressChannel) } fun showMountedNotification(mountStatus: MountStatus) { @@ -106,4 +120,68 @@ class NotificationHelper(private val context: Context) { fun hideNotification() { notificationManager.cancel(NOTIFICATION_ID) } + + fun showCreateProgressNotification(fileName: String, progress: Int, bytesWritten: Long, totalBytes: Long) { + val writtenMB = bytesWritten / (1024 * 1024) + val totalMB = totalBytes / (1024 * 1024) + + // Intent to open the app + val openIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val openPendingIntent = PendingIntent.getActivity( + context, + 2, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Cancel action intent + val cancelIntent = Intent(context, CreateImgReceiver::class.java).apply { + action = ACTION_CANCEL_CREATE + } + val cancelPendingIntent = PendingIntent.getBroadcast( + context, + 3, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID_PROGRESS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Creating $fileName") + .setContentText("$writtenMB MB / $totalMB MB") + .setProgress(100, progress, false) + .setOngoing(true) + .setShowWhen(false) + .setContentIntent(openPendingIntent) + .addAction( + R.drawable.ic_eject, + "Cancel", + cancelPendingIntent + ) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .build() + + notificationManager.notify(NOTIFICATION_ID_PROGRESS, notification) + } + + fun showCreateCompleteNotification(fileName: String, success: Boolean) { + hideProgressNotification() + + val notification = NotificationCompat.Builder(context, CHANNEL_ID_PROGRESS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(if (success) "Image Created" else "Creation Failed") + .setContentText(fileName) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + notificationManager.notify(NOTIFICATION_ID_PROGRESS, notification) + } + + fun hideProgressNotification() { + notificationManager.cancel(NOTIFICATION_ID_PROGRESS) + } } diff --git a/app/src/main/java/sh/sar/isodroid/ui/components/CreateImgDialog.kt b/app/src/main/java/sh/sar/isodroid/ui/components/CreateImgDialog.kt new file mode 100644 index 0000000..4f7159f --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/components/CreateImgDialog.kt @@ -0,0 +1,234 @@ +package sh.sar.isodroid.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +data class CreateImgOptions( + val fileName: String, + val sizeValue: Long, + val sizeUnit: SizeUnit +) { + val totalBytes: Long + get() = sizeValue * sizeUnit.bytes +} + +enum class SizeUnit(val label: String, val bytes: Long) { + MB("MB", 1024L * 1024L), + GB("GB", 1024L * 1024L * 1024L) +} + +data class CreateImgProgress( + val isCreating: Boolean = false, + val fileName: String = "", + val progress: Float = 0f, + val bytesWritten: Long = 0L, + val totalBytes: Long = 0L +) { + val progressText: String + get() { + val writtenMB = bytesWritten / (1024 * 1024) + val totalMB = totalBytes / (1024 * 1024) + return "$writtenMB MB / $totalMB MB" + } +} + +@Composable +fun CreateImgDialog( + progress: CreateImgProgress, + onDismiss: () -> Unit, + onConfirm: (CreateImgOptions) -> Unit, + onCancel: () -> Unit +) { + var fileName by remember { mutableStateOf("") } + var sizeValue by remember { mutableStateOf("") } + var sizeUnit by remember { mutableStateOf(SizeUnit.GB) } + + val isValidInput = fileName.isNotBlank() && + !fileName.contains("/") && + sizeValue.toLongOrNull()?.let { it > 0 } == true + + AlertDialog( + onDismissRequest = { + if (!progress.isCreating) onDismiss() + }, + title = { + Text( + text = if (progress.isCreating) "Creating Image" else "Create IMG File", + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column { + if (progress.isCreating) { + // Show progress + Text( + text = progress.fileName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = { progress.progress }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = progress.progressText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "${(progress.progress * 100).toInt()}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } else { + // Input form + Text( + text = "Create a blank IMG file that can be mounted as a writable USB drive.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // File name input + OutlinedTextField( + value = fileName, + onValueChange = { fileName = it.replace("/", "") }, + label = { Text("File Name") }, + suffix = { Text(".img") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Size input + Text( + text = "Size", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = sizeValue, + onValueChange = { newValue -> + if (newValue.isEmpty() || newValue.toLongOrNull() != null) { + sizeValue = newValue + } + }, + label = { Text("Size") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(Modifier.selectableGroup()) { + SizeUnit.entries.forEach { unit -> + Row( + Modifier + .selectable( + selected = sizeUnit == unit, + onClick = { sizeUnit = unit }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = sizeUnit == unit, + onClick = null + ) + Text( + text = unit.label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } + } + + // Size warning for large files + val totalBytes = (sizeValue.toLongOrNull() ?: 0L) * sizeUnit.bytes + if (totalBytes > 4L * 1024 * 1024 * 1024) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Large files may take a while to create.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + }, + confirmButton = { + if (progress.isCreating) { + TextButton(onClick = onCancel) { + Text("Cancel") + } + } else { + TextButton( + onClick = { + onConfirm( + CreateImgOptions( + fileName = fileName, + sizeValue = sizeValue.toLongOrNull() ?: 0L, + sizeUnit = sizeUnit + ) + ) + }, + enabled = isValidInput + ) { + Text("Create") + } + } + }, + dismissButton = { + if (!progress.isCreating) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + } + ) +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt b/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt index 82b141c..ba92760 100644 --- a/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Eject import androidx.compose.material.icons.filled.Refresh @@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import sh.sar.isodroid.data.IsoFile import sh.sar.isodroid.data.MountOptions +import sh.sar.isodroid.ui.components.CreateImgDialog import sh.sar.isodroid.ui.components.FileBrowser import sh.sar.isodroid.ui.components.MountDialog import sh.sar.isodroid.ui.components.StatusCard @@ -55,10 +57,13 @@ fun MainScreen( var selectedFile by remember { mutableStateOf(null) } var showMountDialog by remember { mutableStateOf(false) } + var showCreateImgDialog by remember { mutableStateOf(false) } // Show error messages LaunchedEffect(uiState.errorMessage) { uiState.errorMessage?.let { message -> + // Close create dialog if open + showCreateImgDialog = false snackbarHostState.showSnackbar(message) viewModel.clearError() } @@ -67,6 +72,8 @@ fun MainScreen( // Show success messages LaunchedEffect(uiState.successMessage) { uiState.successMessage?.let { message -> + // Close create dialog if open + showCreateImgDialog = false snackbarHostState.showSnackbar(message) viewModel.clearSuccess() } @@ -81,6 +88,15 @@ fun MainScreen( titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer ), actions = { + IconButton( + onClick = { showCreateImgDialog = true }, + enabled = uiState.hasRoot && uiState.isSupported + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Create IMG" + ) + } IconButton(onClick = { viewModel.refresh() }) { Icon( imageVector = Icons.Default.Refresh, @@ -181,4 +197,21 @@ fun MainScreen( ) } } + + // Create IMG dialog + if (showCreateImgDialog || uiState.createImgProgress.isCreating) { + CreateImgDialog( + progress = uiState.createImgProgress, + onDismiss = { + showCreateImgDialog = false + }, + onConfirm = { options -> + viewModel.createImg(options) + }, + onCancel = { + viewModel.cancelCreateImg() + showCreateImgDialog = false + } + ) + } } diff --git a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt index dab7067..123302f 100644 --- a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt +++ b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt @@ -19,12 +19,16 @@ import kotlinx.coroutines.launch import sh.sar.isodroid.data.IsoFile import sh.sar.isodroid.data.MountOptions import sh.sar.isodroid.data.MountStatus +import sh.sar.isodroid.isodrive.CreateImgEvent +import sh.sar.isodroid.isodrive.CreateImgEventBus import sh.sar.isodroid.isodrive.IsoDriveManager import sh.sar.isodroid.isodrive.MountEvent import sh.sar.isodroid.isodrive.MountEventBus import sh.sar.isodroid.isodrive.SupportStatus import sh.sar.isodroid.notification.NotificationHelper import sh.sar.isodroid.root.RootManager +import sh.sar.isodroid.ui.components.CreateImgOptions +import sh.sar.isodroid.ui.components.CreateImgProgress import java.io.File private val Application.dataStore: DataStore by preferencesDataStore(name = "settings") @@ -39,6 +43,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val uiState: StateFlow = _uiState.asStateFlow() private var navigationStack = mutableListOf() + private var currentCreateImgFileName = "" companion object { private val KEY_ISO_DIRECTORY = stringPreferencesKey("iso_directory") @@ -62,6 +67,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } } + + // Observe create img events + viewModelScope.launch { + CreateImgEventBus.events.collect { event -> + when (event) { + is CreateImgEvent.Progress -> { + val progress = event.bytesWritten.toFloat() / event.totalBytes.toFloat() + _uiState.update { + it.copy( + createImgProgress = CreateImgProgress( + isCreating = true, + fileName = currentCreateImgFileName, + progress = progress, + bytesWritten = event.bytesWritten, + totalBytes = event.totalBytes + ) + ) + } + notificationHelper.showCreateProgressNotification( + currentCreateImgFileName, + (progress * 100).toInt(), + event.bytesWritten, + event.totalBytes + ) + } + is CreateImgEvent.Complete -> { + _uiState.update { + it.copy( + createImgProgress = CreateImgProgress(), + successMessage = if (event.success) "Image created successfully" else "Failed to create image" + ) + } + notificationHelper.showCreateCompleteNotification( + event.filePath?.substringAfterLast("/") ?: "image.img", + event.success + ) + if (event.success) { + loadFiles() + } + } + is CreateImgEvent.Cancelled -> { + _uiState.update { + it.copy( + createImgProgress = CreateImgProgress(), + successMessage = "Image creation cancelled" + ) + } + notificationHelper.hideProgressNotification() + } + } + } + } } fun initialize() { @@ -351,6 +408,99 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + fun createImg(options: CreateImgOptions) { + viewModelScope.launch { + val fileName = "${options.fileName}.img" + currentCreateImgFileName = fileName + val filePath = "${_uiState.value.currentPath}/$fileName" + val totalBytes = options.totalBytes + + // Check if file already exists + val checkResult = RootManager.executeCommand("test -f \"$filePath\" && echo exists") + if (checkResult.output.trim() == "exists") { + _uiState.update { it.copy(errorMessage = "File already exists: $fileName") } + return@launch + } + + // Start creating + _uiState.update { + it.copy( + createImgProgress = CreateImgProgress( + isCreating = true, + fileName = fileName, + progress = 0f, + bytesWritten = 0, + totalBytes = totalBytes + ) + ) + } + + CreateImgEventBus.resetCancel() + + // Use dd with progress reporting + // We'll create in chunks and report progress + val blockSize = 1024 * 1024L // 1MB blocks + val totalBlocks = totalBytes / blockSize + var writtenBlocks = 0L + + // Create file with dd in background, checking for cancellation + val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + try { + // First, create the file with truncate to reserve space indication + val createResult = RootManager.executeCommand( + "dd if=/dev/zero of=\"$filePath\" bs=1M count=0 seek=$totalBlocks 2>/dev/null" + ) + + if (!createResult.success) { + return@withContext false + } + + // Now fill with zeros in chunks for progress reporting + while (writtenBlocks < totalBlocks) { + if (CreateImgEventBus.isCancelRequested()) { + // Clean up partial file + RootManager.executeCommand("rm -f \"$filePath\"") + return@withContext false + } + + // Write a chunk (up to 64MB at a time for efficiency) + val chunksToWrite = minOf(64, totalBlocks - writtenBlocks) + val chunkResult = RootManager.executeCommand( + "dd if=/dev/zero of=\"$filePath\" bs=1M count=$chunksToWrite seek=$writtenBlocks conv=notrunc 2>/dev/null" + ) + + if (!chunkResult.success) { + RootManager.executeCommand("rm -f \"$filePath\"") + return@withContext false + } + + writtenBlocks += chunksToWrite + val bytesWritten = writtenBlocks * blockSize + + CreateImgEventBus.emitProgress(bytesWritten, totalBytes) + } + + true + } catch (e: Exception) { + RootManager.executeCommand("rm -f \"$filePath\"") + false + } + } + + if (CreateImgEventBus.isCancelRequested()) { + CreateImgEventBus.emitComplete(false, null) + } else { + CreateImgEventBus.emitComplete(result, if (result) filePath else null) + } + } + } + + fun cancelCreateImg() { + viewModelScope.launch { + CreateImgEventBus.cancel() + } + } + fun clearError() { _uiState.update { it.copy(errorMessage = null) } } @@ -370,5 +520,6 @@ data class MainUiState( val currentPath: String = "", val isoDirectory: String = "", val errorMessage: String? = null, - val successMessage: String? = null + val successMessage: String? = null, + val createImgProgress: CreateImgProgress = CreateImgProgress() )