new feature: create custom images
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user