new feature: create custom images

This commit is contained in:
2026-03-10 04:21:44 +05:00
parent ffdb600c1c
commit 800f0fa15a
7 changed files with 562 additions and 5 deletions

View File

@@ -42,6 +42,11 @@
android:name=".notification.UnmountReceiver" android:name=".notification.UnmountReceiver"
android:exported="false" /> android:exported="false" />
<!-- Broadcast receiver for cancel create IMG action from notification -->
<receiver
android:name=".notification.CreateImgReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -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<CreateImgEvent>()
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
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -16,8 +16,11 @@ class NotificationHelper(private val context: Context) {
companion object { companion object {
const val CHANNEL_ID = "iso_drive_mount_status" 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 = 1001
const val NOTIFICATION_ID_PROGRESS = 1002
const val ACTION_UNMOUNT = "sh.sar.isodroid.ACTION_UNMOUNT" const val ACTION_UNMOUNT = "sh.sar.isodroid.ACTION_UNMOUNT"
const val ACTION_CANCEL_CREATE = "sh.sar.isodroid.ACTION_CANCEL_CREATE"
@Volatile @Volatile
private var instance: NotificationHelper? = null 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 private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
init { init {
createNotificationChannel() createNotificationChannels()
} }
private fun createNotificationChannel() { private fun createNotificationChannels() {
val channel = NotificationChannel( val mountChannel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Mount Status", "Mount Status",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
@@ -44,7 +47,18 @@ class NotificationHelper(private val context: Context) {
description = "Shows when an ISO/IMG file is mounted" description = "Shows when an ISO/IMG file is mounted"
setShowBadge(false) 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) { fun showMountedNotification(mountStatus: MountStatus) {
@@ -106,4 +120,68 @@ class NotificationHelper(private val context: Context) {
fun hideNotification() { fun hideNotification() {
notificationManager.cancel(NOTIFICATION_ID) 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)
}
} }

View File

@@ -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")
}
}
}
)
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons 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.Download
import androidx.compose.material.icons.filled.Eject import androidx.compose.material.icons.filled.Eject
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sh.sar.isodroid.data.IsoFile import sh.sar.isodroid.data.IsoFile
import sh.sar.isodroid.data.MountOptions 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.FileBrowser
import sh.sar.isodroid.ui.components.MountDialog import sh.sar.isodroid.ui.components.MountDialog
import sh.sar.isodroid.ui.components.StatusCard import sh.sar.isodroid.ui.components.StatusCard
@@ -55,10 +57,13 @@ fun MainScreen(
var selectedFile by remember { mutableStateOf<IsoFile?>(null) } var selectedFile by remember { mutableStateOf<IsoFile?>(null) }
var showMountDialog by remember { mutableStateOf(false) } var showMountDialog by remember { mutableStateOf(false) }
var showCreateImgDialog by remember { mutableStateOf(false) }
// Show error messages // Show error messages
LaunchedEffect(uiState.errorMessage) { LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { message -> uiState.errorMessage?.let { message ->
// Close create dialog if open
showCreateImgDialog = false
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
viewModel.clearError() viewModel.clearError()
} }
@@ -67,6 +72,8 @@ fun MainScreen(
// Show success messages // Show success messages
LaunchedEffect(uiState.successMessage) { LaunchedEffect(uiState.successMessage) {
uiState.successMessage?.let { message -> uiState.successMessage?.let { message ->
// Close create dialog if open
showCreateImgDialog = false
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
viewModel.clearSuccess() viewModel.clearSuccess()
} }
@@ -81,6 +88,15 @@ fun MainScreen(
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
), ),
actions = { actions = {
IconButton(
onClick = { showCreateImgDialog = true },
enabled = uiState.hasRoot && uiState.isSupported
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "Create IMG"
)
}
IconButton(onClick = { viewModel.refresh() }) { IconButton(onClick = { viewModel.refresh() }) {
Icon( Icon(
imageVector = Icons.Default.Refresh, 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
}
)
}
} }

View File

@@ -19,12 +19,16 @@ import kotlinx.coroutines.launch
import sh.sar.isodroid.data.IsoFile import sh.sar.isodroid.data.IsoFile
import sh.sar.isodroid.data.MountOptions import sh.sar.isodroid.data.MountOptions
import sh.sar.isodroid.data.MountStatus 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.IsoDriveManager
import sh.sar.isodroid.isodrive.MountEvent import sh.sar.isodroid.isodrive.MountEvent
import sh.sar.isodroid.isodrive.MountEventBus import sh.sar.isodroid.isodrive.MountEventBus
import sh.sar.isodroid.isodrive.SupportStatus import sh.sar.isodroid.isodrive.SupportStatus
import sh.sar.isodroid.notification.NotificationHelper import sh.sar.isodroid.notification.NotificationHelper
import sh.sar.isodroid.root.RootManager import sh.sar.isodroid.root.RootManager
import sh.sar.isodroid.ui.components.CreateImgOptions
import sh.sar.isodroid.ui.components.CreateImgProgress
import java.io.File import java.io.File
private val Application.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private val Application.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -39,6 +43,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow() val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
private var navigationStack = mutableListOf<String>() private var navigationStack = mutableListOf<String>()
private var currentCreateImgFileName = ""
companion object { companion object {
private val KEY_ISO_DIRECTORY = stringPreferencesKey("iso_directory") 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() { 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() { fun clearError() {
_uiState.update { it.copy(errorMessage = null) } _uiState.update { it.copy(errorMessage = null) }
} }
@@ -370,5 +520,6 @@ data class MainUiState(
val currentPath: String = "", val currentPath: String = "",
val isoDirectory: String = "", val isoDirectory: String = "",
val errorMessage: String? = null, val errorMessage: String? = null,
val successMessage: String? = null val successMessage: String? = null,
val createImgProgress: CreateImgProgress = CreateImgProgress()
) )