new feature: create custom images
This commit is contained in:
@@ -42,6 +42,11 @@
|
||||
android:name=".notification.UnmountReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Broadcast receiver for cancel create IMG action from notification -->
|
||||
<receiver
|
||||
android:name=".notification.CreateImgReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<IsoFile?>(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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Preferences> by preferencesDataStore(name = "settings")
|
||||
@@ -39,6 +43,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var navigationStack = mutableListOf<String>()
|
||||
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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user