15 Commits
v1.5 ... v1.6

24 changed files with 422 additions and 42 deletions

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-12T19:54:01.237140412Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6] - 2026-03-13
### Added
- Disclaimer screen in welcome wizard about user responsibility
- USB services restart warning dialog before mount/unmount operations
- Toggle in Settings to enable/disable USB restart warning
### Fixed
- Prevent shell escape exploits in shell commands
- Disable logcat on release builds
## [1.5] - 2026-03-12
### Added

View File

@@ -1,12 +1,17 @@
# ISO Droid
> **Note:** This app requires root access and was developed with AI assistance (Claude).
> I use it on my own devices, but as with any root tool, understand what you're running and keep backups.
>
> See [full disclaimer](docs/DISCLAIMER.md) | [Report issues](https://git.sargit.com/sargit/ISODroid/issues)
Android app for mounting ISO/IMG files as USB mass storage or CD-ROM devices on rooted Android devices.
## Screenshots
| OS images listing | Mounted Status | Mount Options Dialog | Create IMG Dialog | Download OS ISOs |
| OS images listing | Mount Options Dialog | Mounted Status | Create IMG Dialog | Download OS ISOs |
|:--:|:--:|:--:|:--:|:--:|
| ![File Browser](docs/screenshots/list_images_home.jpg) | ![Mounted Status](docs/screenshots/mounted_status_home.jpg) | ![Mount Options](docs/screenshots/mount_options_dialogbox.jpg) | ![Create IMG](docs/screenshots/create_img_digalogbox.jpg) | ![Download ISOs](docs/screenshots/list_listing_download.jpg) |
| ![File Browser](docs/screenshots/list_images_home.jpg) | ![Mount Options](docs/screenshots/mount_options_dialogbox.jpg) | ![Mounted Status](docs/screenshots/mounted_status_home.jpg) | ![Create IMG](docs/screenshots/create_img_digalogbox.jpg) | ![Download ISOs](docs/screenshots/list_listing_download.jpg) |
## Features

View File

@@ -12,8 +12,8 @@ android {
applicationId = "sh.sar.isodroid"
minSdk = 26
targetSdk = 36
versionCode = 5
versionName = "1.5"
versionCode = 6
versionName = "1.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -25,7 +25,7 @@ class ISODroidApp : Application() {
companion object {
init {
// Set settings before the main shell can be created
Shell.enableVerboseLogging = true
Shell.enableVerboseLogging = BuildConfig.DEBUG
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)

View File

@@ -150,8 +150,9 @@ class IsoDriveManager(private val context: Context) {
)
}
// Validate file exists
val fileCheck = RootManager.executeCommand("test -f \"$isoPath\" && echo exists")
// Validate file exists using shell-safe escaping
val safePath = RootManager.shellEscape(isoPath)
val fileCheck = RootManager.executeCommand("test -f $safePath && echo exists")
if (!fileCheck.success || !fileCheck.output.contains("exists")) {
return@withContext MountResult(
success = false,
@@ -167,9 +168,9 @@ class IsoDriveManager(private val context: Context) {
)
}
// Build command
// Build command with safe path escaping
val args = options.toCommandArgs().joinToString(" ")
val command = "$binaryPath \"$isoPath\" $args"
val command = "$binaryPath $safePath $args"
val result = RootManager.executeCommand(command)

View File

@@ -6,16 +6,29 @@
package sh.sar.isodroid.root
import com.topjohnwu.superuser.Shell
import sh.sar.isodroid.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object RootManager {
/**
* Escapes a string for safe use in shell commands.
* Uses single quotes and escapes any single quotes within the string.
* This prevents command injection via $(), ``, ;, &&, ||, etc.
*/
fun shellEscape(s: String): String {
// Single quotes prevent all shell interpretation except for single quotes themselves
// To include a single quote, we end the single-quoted string, add an escaped single quote, and start a new single-quoted string
// Example: "test'file" becomes 'test'\''file'
return "'" + s.replace("'", "'\\''") + "'"
}
init {
Shell.enableVerboseLogging = true
Shell.enableVerboseLogging = BuildConfig.DEBUG
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_REDIRECT_STDERR)
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setTimeout(10)
)
}

View File

@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2026 Shiham Abdul Rahman <shihaam@shihaam.dev>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
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.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
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.unit.dp
@Composable
fun UsbWarningDialog(
isUnmount: Boolean,
onDismiss: () -> Unit,
onConfirm: (dontShowAgain: Boolean) -> Unit
) {
var dontShowAgain by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "USB Services Will Restart",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column {
Text(
text = if (isUnmount) {
"Unmounting will restart USB services on your device, including:"
} else {
"Mounting will restart USB services on your device, including:"
},
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "• MTP file transfer\n• USB ADB\n• USB tethering\n• Other USB functions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Active file transfers may be interrupted and could result in data corruption. Make sure no transfers are in progress.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = dontShowAgain,
onCheckedChange = { dontShowAgain = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Don't show this again",
style = MaterialTheme.typography.bodyMedium
)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(dontShowAgain) }
) {
Text(if (isUnmount) "Unmount" else "Mount")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -49,14 +49,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.FileContextMenu
import sh.sar.isodroid.ui.components.FileItemCard
import sh.sar.isodroid.ui.components.MountDialog
import sh.sar.isodroid.ui.components.StatusCard
import sh.sar.isodroid.ui.components.UsbWarningDialog
import sh.sar.isodroid.viewmodel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -69,12 +72,38 @@ fun MainScreen(
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
var selectedFile by remember { mutableStateOf<IsoFile?>(null) }
var showMountDialog by remember { mutableStateOf(false) }
var showCreateImgDialog by remember { mutableStateOf(false) }
var contextMenuFile by remember { mutableStateOf<IsoFile?>(null) }
// USB warning dialog state
var showUsbWarning by remember { mutableStateOf(false) }
var pendingMountPath by remember { mutableStateOf<String?>(null) }
var pendingMountOptions by remember { mutableStateOf<MountOptions?>(null) }
var isUnmountWarning by remember { mutableStateOf(false) }
val prefs = remember { context.getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) }
val skipUsbWarning = remember { mutableStateOf(prefs.getBoolean("skip_usb_warning", false)) }
fun showUsbWarningOrProceed(
isUnmount: Boolean,
mountPath: String? = null,
mountOptions: MountOptions? = null,
onProceed: () -> Unit
) {
if (skipUsbWarning.value) {
onProceed()
} else {
isUnmountWarning = isUnmount
pendingMountPath = mountPath
pendingMountOptions = mountOptions
showUsbWarning = true
}
}
val pullToRefreshState = rememberPullToRefreshState()
// Handle pull-to-refresh
@@ -150,8 +179,10 @@ fun MainScreen(
if (uiState.mountStatus.mounted) {
ExtendedFloatingActionButton(
onClick = {
scope.launch {
viewModel.unmount()
showUsbWarningOrProceed(isUnmount = true) {
scope.launch {
viewModel.unmount()
}
}
},
icon = {
@@ -295,8 +326,14 @@ fun MainScreen(
val filePath = file.path // Capture path before clearing state
showMountDialog = false
selectedFile = null
scope.launch {
viewModel.mount(filePath, options)
showUsbWarningOrProceed(
isUnmount = false,
mountPath = filePath,
mountOptions = options
) {
scope.launch {
viewModel.mount(filePath, options)
}
}
}
)
@@ -337,4 +374,39 @@ fun MainScreen(
}
)
}
// USB warning dialog
if (showUsbWarning) {
UsbWarningDialog(
isUnmount = isUnmountWarning,
onDismiss = {
showUsbWarning = false
pendingMountPath = null
pendingMountOptions = null
},
onConfirm = { dontShowAgain ->
if (dontShowAgain) {
prefs.edit().putBoolean("skip_usb_warning", true).apply()
skipUsbWarning.value = true
}
// Capture values before clearing state
val isUnmount = isUnmountWarning
val path = pendingMountPath
val options = pendingMountOptions
showUsbWarning = false
pendingMountPath = null
pendingMountOptions = null
scope.launch {
if (isUnmount) {
viewModel.unmount()
} else if (path != null && options != null) {
viewModel.mount(path, options)
}
}
}
)
}
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -58,6 +59,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -115,6 +117,10 @@ fun SettingsScreen(
)
}
// USB warning preference
val prefs = remember { context.getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) }
var showUsbWarning by remember { mutableStateOf(!prefs.getBoolean("skip_usb_warning", false)) }
// Re-check permissions when returning to the app
if (activity != null) {
androidx.compose.runtime.DisposableEffect(activity) {
@@ -246,6 +252,61 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Warnings section
SectionHeader(title = "Warnings")
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "USB restart warning",
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Show warning before mount/unmount about USB service interruption",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Switch(
checked = showUsbWarning,
onCheckedChange = { enabled ->
showUsbWarning = enabled
prefs.edit().putBoolean("skip_usb_warning", !enabled).apply()
}
)
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// About section
SectionHeader(title = "About")
@@ -456,9 +517,10 @@ private fun DirectoryBrowserDialog(
fun loadContents(path: String) {
scope.launch {
isLoading = true
val safePath = RootManager.shellEscape(path)
// Load directories
val dirResult = RootManager.executeCommand(
"find \"$path\" -maxdepth 1 -mindepth 1 -type d 2>/dev/null"
"find $safePath -maxdepth 1 -mindepth 1 -type d 2>/dev/null"
)
val directories = if (dirResult.success && dirResult.output.isNotBlank()) {
dirResult.output.lines()
@@ -467,8 +529,9 @@ private fun DirectoryBrowserDialog(
.filter { !it.substringAfterLast("/").startsWith(".") }
.map { dirPath ->
// Check if this directory was created by the app (has .isodroiddir marker)
val safeDirPath = RootManager.shellEscape(dirPath)
val markerCheck = RootManager.executeCommand(
"test -f \"$dirPath/.isodroiddir\" && echo 'yes' || echo 'no'"
"test -f $safeDirPath/.isodroiddir && echo 'yes' || echo 'no'"
)
val isDeletable = markerCheck.output.trim() == "yes"
BrowserItem(dirPath.substringAfterLast("/"), true, dirPath, isDeletable)
@@ -479,7 +542,7 @@ private fun DirectoryBrowserDialog(
// Load ISO/IMG files
val fileResult = RootManager.executeCommand(
"find \"$path\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
"find $safePath -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
)
val files = if (fileResult.success && fileResult.output.isNotBlank()) {
fileResult.output.lines()
@@ -501,9 +564,10 @@ private fun DirectoryBrowserDialog(
if (trimmedName.isEmpty()) return@launch
val newPath = "$currentPath/$trimmedName"
RootManager.executeCommand("mkdir -p \"$newPath\"")
val safeNewPath = RootManager.shellEscape(newPath)
RootManager.executeCommand("mkdir -p $safeNewPath")
// Create marker file to indicate this folder was created by the app
RootManager.executeCommand("touch \"$newPath/.isodroiddir\"")
RootManager.executeCommand("touch $safeNewPath/.isodroiddir")
// Auto-navigate into the new folder
currentPath = newPath
}
@@ -511,7 +575,8 @@ private fun DirectoryBrowserDialog(
fun deleteFolder(path: String) {
scope.launch {
RootManager.executeCommand("rm -rf \"$path\"")
val safePath = RootManager.shellEscape(path)
RootManager.executeCommand("rm -rf $safePath")
loadContents(currentPath)
}
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -69,15 +70,18 @@ fun SetupWizardScreen(
0 -> WelcomeStep(
onNext = { currentStep = 1 }
)
1 -> RootAccessStep(
onNext = { currentStep = 2 },
onSkip = { currentStep = 2 }
1 -> DisclaimerStep(
onNext = { currentStep = 2 }
)
2 -> NotificationStep(
2 -> RootAccessStep(
onNext = { currentStep = 3 },
onSkip = { currentStep = 3 }
)
3 -> CompleteStep(
3 -> NotificationStep(
onNext = { currentStep = 4 },
onSkip = { currentStep = 4 }
)
4 -> CompleteStep(
onFinish = onSetupComplete
)
}
@@ -131,6 +135,53 @@ private fun WelcomeStep(
}
}
@Composable
private fun DisclaimerStep(
onNext: () -> Unit
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Before You Continue",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "This app requires root access and performs system-level operations. While reasonable precautions have been taken, you are responsible for understanding what you're doing.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Keep backups of important data. This software is provided \"as is\" without warranty.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue")
}
}
@Composable
private fun RootAccessStep(
onNext: () -> Unit,

View File

@@ -205,9 +205,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Create directory if it doesn't exist
if (!directory.exists()) {
RootManager.executeCommand("mkdir -p \"$currentPath\"")
val safePath = RootManager.shellEscape(currentPath)
RootManager.executeCommand("mkdir -p $safePath")
// Create marker file to indicate this folder was created by the app
RootManager.executeCommand("touch \"$currentPath/.isodroiddir\"")
RootManager.executeCommand("touch $safePath/.isodroiddir")
}
// Try multiple methods to list files
@@ -220,8 +221,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadFilesViaFind(currentPath: String): List<IsoFile>? {
// Use find command - more reliable for getting full paths
val safePath = RootManager.shellEscape(currentPath)
val result = RootManager.executeCommand(
"find \"$currentPath\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
"find $safePath -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
)
if (!result.success || result.output.isBlank()) return null
@@ -231,8 +233,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
.mapNotNull { filePath ->
val file = File(filePath.trim())
val name = file.name
// Get file size via stat
val sizeResult = RootManager.executeCommand("stat -c %s \"$filePath\" 2>/dev/null")
// Get file size via stat with safe escaping
val safeFilePath = RootManager.shellEscape(filePath.trim())
val sizeResult = RootManager.executeCommand("stat -c %s $safeFilePath 2>/dev/null")
val size = sizeResult.output.trim().toLongOrNull() ?: 0L
IsoFile(
path = filePath.trim(),
@@ -246,8 +249,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadFilesViaLs(currentPath: String): List<IsoFile>? {
// Simple ls command - just get filenames
val safePath = RootManager.shellEscape(currentPath)
val result = RootManager.executeCommand(
"ls \"$currentPath\" 2>/dev/null"
"ls $safePath 2>/dev/null"
)
if (!result.success || result.output.isBlank()) return null
@@ -259,8 +263,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
.mapNotNull { name ->
val filePath = "$currentPath/$name"
// Get file size via stat
val sizeResult = RootManager.executeCommand("stat -c %s \"$filePath\" 2>/dev/null")
// Get file size via stat with safe escaping
val safeFilePath = RootManager.shellEscape(filePath)
val sizeResult = RootManager.executeCommand("stat -c %s $safeFilePath 2>/dev/null")
val size = sizeResult.output.trim().toLongOrNull() ?: 0L
IsoFile(
path = filePath,
@@ -444,13 +449,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val blockSize = 1024 * 1024L // 1MB blocks
val totalBlocks = totalBytes / blockSize
var writtenBlocks = 0L
val safeFilePath = RootManager.shellEscape(filePath)
// 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"
"dd if=/dev/zero of=$safeFilePath bs=1M count=0 seek=$totalBlocks 2>/dev/null"
)
if (!createResult.success) {
@@ -461,18 +467,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
while (writtenBlocks < totalBlocks) {
if (CreateImgEventBus.isCancelRequested()) {
// Clean up partial file
RootManager.executeCommand("rm -f \"$filePath\"")
RootManager.executeCommand("rm -f $safeFilePath")
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"
"dd if=/dev/zero of=$safeFilePath bs=1M count=$chunksToWrite seek=$writtenBlocks conv=notrunc 2>/dev/null"
)
if (!chunkResult.success) {
RootManager.executeCommand("rm -f \"$filePath\"")
RootManager.executeCommand("rm -f $safeFilePath")
return@withContext false
}
@@ -484,7 +490,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
true
} catch (e: Exception) {
RootManager.executeCommand("rm -f \"$filePath\"")
RootManager.executeCommand("rm -f $safeFilePath")
false
}
}
@@ -508,14 +514,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val oldPath = file.path
val newPath = "${oldPath.substringBeforeLast("/")}/$newName"
// Use shell-safe escaping to prevent command injection
val safeOldPath = RootManager.shellEscape(oldPath)
val safeNewPath = RootManager.shellEscape(newPath)
// Check if new file already exists
val checkResult = RootManager.executeCommand("test -f \"$newPath\" && echo exists")
val checkResult = RootManager.executeCommand("test -f $safeNewPath && echo exists")
if (checkResult.output.trim() == "exists") {
_uiState.update { it.copy(errorMessage = "File already exists: $newName") }
return@launch
}
val result = RootManager.executeCommand("mv \"$oldPath\" \"$newPath\"")
val result = RootManager.executeCommand("mv $safeOldPath $safeNewPath")
if (result.success) {
_uiState.update { it.copy(successMessage = "Renamed to $newName") }
loadFiles()
@@ -527,7 +537,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun deleteFile(file: IsoFile) {
viewModelScope.launch {
val result = RootManager.executeCommand("rm -f \"${file.path}\"")
val safePath = RootManager.shellEscape(file.path)
val result = RootManager.executeCommand("rm -f $safePath")
if (result.success) {
_uiState.update { it.copy(successMessage = "Deleted ${file.name}") }
loadFiles()

38
docs/DISCLAIMER.md Normal file
View File

@@ -0,0 +1,38 @@
# Disclaimer
## About This Project
ISO Droid was developed with AI assistance (Claude by Anthropic). This is mentioned for transparency, regardless of who or what wrote it, it can contain bugs.
Reasonable precautions have been taken to prevent unintended behavior.
## Root Access
This app requires root access to function. Root access gives applications elevated privileges on your device, which means:
- Operations can affect system-level functionality
- Mistakes can potentially cause issues that require recovery
- You should understand what an operation does before executing it
## Recommendations
- **Keep backups** of important data (good practice regardless)
- **Understand the operations** before running them
- **Test on non-critical setups first** if you're unsure
- **Report bugs** if you encounter them
## My Usage
I (the developer) use this app on my own devices regularly. It works for my use cases, but your device, kernel, and setup may differ, and there may be edge cases I haven't encountered.
## No Warranty
This software is provided "as is" without warranty of any kind. See the [LICENSE](../LICENSE) file for full details (GPL-3.0).
## Bug Reports & Contributions
Found a bug? Have a suggestion? Please open an issue:
- **Issues**: [git.sargit.com/sargit/ISODroid/issues](https://git.sargit.com/sargit/ISODroid/issues)
Contributions are welcome. If you fix something or improve the app, consider submitting a pull request.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -0,0 +1,5 @@
• Added disclaimer screen in welcome wizard about user responsibility
• Added USB services restart warning before mount/unmount (with "Don't show again" option)
• Added toggle in Settings to enable/disable USB restart warning
• Fixed shell escape exploits in shell commands
• Disabled logcat on release builds

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 145 KiB