8 Commits
Author SHA1 Message Date
shihaam f15882aea2 disable logcat on release build 2026-03-13 00:32:13 +05:00
shihaam f3dc0b65d6 attempt to prevent shell escape 2026-03-13 00:25:09 +05:00
shihaam 4b22871ab4 disable logcat on release builds, add shellexcape prevention fun 2026-03-13 00:24:50 +05:00
shihaam d15b56c9c0 AI disclaimer docs 2026-03-12 23:43:12 +05:00
shihaam 88ed16f89e AI disclaimer docs 2026-03-12 23:40:27 +05:00
shihaam 71842b01d4 AI disclaimer docs 2026-03-12 23:35:52 +05:00
shihaam fb74e3663d AI disclaimer docs 2026-03-12 23:29:25 +05:00
shihaam f031a96193 .idea 2026-03-12 18:01:09 +05:00
7 changed files with 100 additions and 28 deletions
+5
View File
@@ -1,5 +1,10 @@
# 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
@@ -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)
@@ -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)
@@ -6,13 +6,26 @@
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)
@@ -456,9 +456,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 +468,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 +481,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 +503,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 +514,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)
}
}
@@ -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
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.