10 Commits
v1.2 ... v1.4

10 changed files with 442 additions and 63 deletions

View File

@@ -5,6 +5,24 @@ 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.4] - 2026-03-11
### Added
- Directory browser for changing ISO directory in settings
- Create new directories from the directory browser
- Delete directories created by the app (long press)
- Shows ISO/IMG files with OS icons in directory browser
### Changed
- Empty state on home screen now shows current path and helpful hints
- Version number is now read dynamically from app config
- Renamed "folder" to "directory" throughout the UI
## [1.3] - 2025-03-11
### Changed
- Fix default image dir to be /sdcard/isodroid instead of /sdcard/isodrive
## [1.2] - 2025-03-10
### Changed

View File

@@ -35,7 +35,7 @@ Android app for mounting ISO/IMG files as USB mass storage or CD-ROM devices on
1. Download the APK from the links above
2. Install the APK on your rooted Android device
3. Grant root access when prompted
4. Place your ISO/IMG files in `/sdcard/isodrive/` (or configure a different directory in settings)
4. Place your ISO/IMG files in `/sdcard/isodroid/` (or configure a different directory in settings)
> **Note**: The app includes a bundled `isodrive` binary. No additional setup required!

View File

@@ -12,8 +12,8 @@ android {
applicationId = "sh.sar.isodroid"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.2"
versionCode = 4
versionName = "1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -55,7 +55,7 @@ import sh.sar.isodroid.data.IsoFile
* 1. Longest match (most specific)
* 2. Earliest position in filename (if same length)
*/
private fun findOsIcon(context: android.content.Context, filename: String): String? {
fun findOsIcon(context: android.content.Context, filename: String): String? {
return try {
// Dynamically load available icon files from assets
val availableIcons = context.assets.list("osicons")
@@ -111,10 +111,19 @@ fun FileBrowser(
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Place ISO or IMG files in this directory",
text = "Place ISO or IMG files in:\n$currentPath",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Tap + to create an empty IMG file\nChange directory in Settings",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
} else {

View File

@@ -47,30 +47,6 @@ import sh.sar.isodroid.ui.theme.MountedGreen
import sh.sar.isodroid.ui.theme.UnmountedGray
import java.io.File
/**
* Finds a matching OS icon filename for a given file by dynamically checking available icons.
*/
private fun findOsIcon(context: android.content.Context, filename: String): String? {
return try {
val availableIcons = context.assets.list("osicons")
?.filter { it.endsWith(".svg", ignoreCase = true) }
?.map { it.removeSuffix(".svg").lowercase() }
?: emptyList()
val lowerFilename = filename.lowercase()
availableIcons
.filter { lowerFilename.contains(it) }
.maxWithOrNull(compareBy(
{ it.length },
{ -lowerFilename.indexOf(it) }
))
?.let { "$it.svg" }
} catch (e: Exception) {
null
}
}
@Composable
fun StatusCard(
mountStatus: MountStatus,

View File

@@ -10,23 +10,38 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Album
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
@@ -35,6 +50,7 @@ import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -47,20 +63,33 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import kotlinx.coroutines.launch
import sh.sar.isodroid.root.RootManager
import sh.sar.isodroid.ui.components.findOsIcon
import sh.sar.isodroid.viewmodel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -73,7 +102,6 @@ fun SettingsScreen(
val context = LocalContext.current
val activity = context as? androidx.activity.ComponentActivity
var showPathDialog by remember { mutableStateOf(false) }
var tempPath by remember(uiState.currentPath) { mutableStateOf(uiState.isoDirectory) }
// Track notification permission with lifecycle-aware refresh
var hasNotificationPermission by remember {
@@ -234,8 +262,15 @@ fun SettingsScreen(
text = "ISO Droid",
style = MaterialTheme.typography.titleMedium
)
val versionName = remember {
try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (e: Exception) {
"Unknown"
}
}
Text(
text = "Version 1.2",
text = "Version $versionName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -369,40 +404,378 @@ fun SettingsScreen(
}
}
// Path edit dialog
// Directory browser dialog
if (showPathDialog) {
AlertDialog(
onDismissRequest = { showPathDialog = false },
title = { Text("ISO Directory") },
text = {
Column {
Text(
text = "Enter the path to the directory containing your ISO/IMG files.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = tempPath,
onValueChange = { tempPath = it },
label = { Text("Path") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
DirectoryBrowserDialog(
initialPath = uiState.isoDirectory,
onDismiss = { showPathDialog = false },
onSelect = { selectedPath ->
viewModel.setIsoDirectory(selectedPath)
showPathDialog = false
}
)
}
}
private data class BrowserItem(
val name: String,
val isDirectory: Boolean,
val fullPath: String,
val isDeletable: Boolean = false
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DirectoryBrowserDialog(
initialPath: String,
onDismiss: () -> Unit,
onSelect: (String) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val hapticFeedback = LocalHapticFeedback.current
var currentPath by remember { mutableStateOf(initialPath) }
var items by remember { mutableStateOf<List<BrowserItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var showCreateFolderDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf<String?>(null) }
var newFolderName by remember { mutableStateOf("") }
val storageRoot = Environment.getExternalStorageDirectory().absolutePath
fun loadContents(path: String) {
scope.launch {
isLoading = true
// Load directories
val dirResult = RootManager.executeCommand(
"find \"$path\" -maxdepth 1 -mindepth 1 -type d 2>/dev/null"
)
val directories = if (dirResult.success && dirResult.output.isNotBlank()) {
dirResult.output.lines()
.filter { it.isNotBlank() }
.map { it.trim() }
.filter { !it.substringAfterLast("/").startsWith(".") }
.map { dirPath ->
// Check if this directory was created by the app (has .isodroiddir marker)
val markerCheck = RootManager.executeCommand(
"test -f \"$dirPath/.isodroiddir\" && echo 'yes' || echo 'no'"
)
val isDeletable = markerCheck.output.trim() == "yes"
BrowserItem(dirPath.substringAfterLast("/"), true, dirPath, isDeletable)
}
} else {
emptyList()
}
// Load ISO/IMG files
val fileResult = RootManager.executeCommand(
"find \"$path\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
)
val files = if (fileResult.success && fileResult.output.isNotBlank()) {
fileResult.output.lines()
.filter { it.isNotBlank() }
.map { it.trim() }
.map { BrowserItem(it.substringAfterLast("/"), false, it) }
} else {
emptyList()
}
items = (directories.sortedBy { it.name.lowercase() } + files.sortedBy { it.name.lowercase() })
isLoading = false
}
}
fun createFolder(name: String) {
scope.launch {
val trimmedName = name.trim()
if (trimmedName.isEmpty()) return@launch
val newPath = "$currentPath/$trimmedName"
RootManager.executeCommand("mkdir -p \"$newPath\"")
// Create marker file to indicate this folder was created by the app
RootManager.executeCommand("touch \"$newPath/.isodroiddir\"")
// Auto-navigate into the new folder
currentPath = newPath
}
}
fun deleteFolder(path: String) {
scope.launch {
RootManager.executeCommand("rm -rf \"$path\"")
loadContents(currentPath)
}
}
LaunchedEffect(currentPath) {
loadContents(currentPath)
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Select Directory")
IconButton(onClick = { showCreateFolderDialog = true }) {
Icon(
imageVector = Icons.Default.CreateNewFolder,
contentDescription = "Create directory",
tint = MaterialTheme.colorScheme.primary
)
}
}
},
text = {
Column {
// Current path display
Text(
text = currentPath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
// Content list
if (isLoading) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 200.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 200.dp, max = 300.dp)
) {
// Parent directory (..)
if (currentPath != "/" && currentPath != storageRoot) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val parent = currentPath.substringBeforeLast("/")
currentPath = parent.ifEmpty { "/" }
}
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "..",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
// Items (directories and files)
items(items) { item ->
val isIso = item.name.lowercase().endsWith(".iso")
val isImg = item.name.lowercase().endsWith(".img")
val osIcon = if (!item.isDirectory) findOsIcon(context, item.name) else null
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (item.isDirectory) {
currentPath = item.fullPath
}
},
onLongClick = {
if (item.isDirectory) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
when {
item.fullPath == initialPath -> {
Toast.makeText(
context,
"Can't delete your current ISO directory. Change it first, then try again.",
Toast.LENGTH_SHORT
).show()
}
item.isDeletable -> {
showDeleteDialog = item.fullPath
}
else -> {
Toast.makeText(
context,
"Can't delete this directory — it wasn't created by ISO Droid",
Toast.LENGTH_SHORT
).show()
}
}
}
}
)
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with badge
Box(modifier = Modifier.size(32.dp)) {
if (item.isDirectory) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxSize()
)
} else if (osIcon != null) {
// OS icon with file type badge
AsyncImage(
model = ImageRequest.Builder(context)
.data("file:///android_asset/osicons/$osIcon")
.decoderFactory(SvgDecoder.Factory())
.build(),
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
// File type badge
Icon(
imageVector = if (isIso) Icons.Default.Album else Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.size(14.dp)
.align(Alignment.BottomEnd)
.offset(x = 2.dp, y = 2.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(2.dp)
)
} else {
// Fallback icon
Icon(
imageVector = if (isIso) Icons.Default.Album else Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxSize()
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = item.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
color = if (item.isDirectory)
MaterialTheme.colorScheme.onSurface
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Empty state
if (items.isEmpty()) {
item {
Text(
text = "Empty directory",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp, horizontal = 4.dp)
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { onSelect(currentPath) }) {
Text("Select")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
// Create folder dialog
if (showCreateFolderDialog) {
AlertDialog(
onDismissRequest = {
showCreateFolderDialog = false
newFolderName = ""
},
title = { Text("Create Directory") },
text = {
OutlinedTextField(
value = newFolderName,
onValueChange = { newFolderName = it },
label = { Text("Directory name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.setIsoDirectory(tempPath)
showPathDialog = false
}
if (newFolderName.isNotBlank()) {
createFolder(newFolderName)
showCreateFolderDialog = false
newFolderName = ""
}
},
enabled = newFolderName.isNotBlank()
) {
Text("Save")
Text("Create")
}
},
dismissButton = {
TextButton(onClick = { showPathDialog = false }) {
TextButton(onClick = {
showCreateFolderDialog = false
newFolderName = ""
}) {
Text("Cancel")
}
}
)
}
// Delete confirmation dialog
showDeleteDialog?.let { pathToDelete ->
AlertDialog(
onDismissRequest = { showDeleteDialog = null },
title = { Text("Delete Directory") },
text = {
Text("Are you sure you want to delete \"${pathToDelete.substringAfterLast("/")}\" and all its contents?")
},
confirmButton = {
TextButton(
onClick = {
deleteFolder(pathToDelete)
showDeleteDialog = null
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = null }) {
Text("Cancel")
}
}

View File

@@ -8,6 +8,7 @@ package sh.sar.isodroid.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -323,7 +324,7 @@ private fun CompleteStep(
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "ISO Droid is ready to use. Place your ISO or IMG files in the isodrive folder and start mounting.",
text = "ISO Droid is ready to use. Place your ISO or IMG files in the isodroid directory and start mounting.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -332,7 +333,7 @@ private fun CompleteStep(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default directory: /sdcard/isodrive/",
text = "Default directory: ${Environment.getExternalStorageDirectory().absolutePath}/isodroid/",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)

View File

@@ -52,7 +52,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val KEY_ISO_DIRECTORY = stringPreferencesKey("iso_directory")
private val DEFAULT_ISO_DIRECTORY = "${Environment.getExternalStorageDirectory().absolutePath}/isodrive"
private val DEFAULT_ISO_DIRECTORY = "${Environment.getExternalStorageDirectory().absolutePath}/isodroid"
}
private var initialized = false
@@ -206,6 +206,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Create directory if it doesn't exist
if (!directory.exists()) {
RootManager.executeCommand("mkdir -p \"$currentPath\"")
// Create marker file to indicate this folder was created by the app
RootManager.executeCommand("touch \"$currentPath/.isodroiddir\"")
}
// Try multiple methods to list files

View File

@@ -229,13 +229,13 @@ fun toCommandArgs(): List<String> {
**Example commands:**
```bash
# Mount as read-only mass storage
isodrive "/sdcard/isodrive/ubuntu.iso" -configfs
isodrive "/sdcard/isodroid/ubuntu.iso" -configfs
# Mount as writable drive
isodrive "/sdcard/isodrive/drive.img" -rw -configfs
isodrive "/sdcard/isodroid/drive.img" -rw -configfs
# Mount as CD-ROM
isodrive "/sdcard/isodrive/windows.iso" -cdrom -configfs
isodrive "/sdcard/isodroid/windows.iso" -cdrom -configfs
```
## Event System
@@ -338,7 +338,7 @@ private val KEY_ISO_DIRECTORY = stringPreferencesKey("iso_directory")
```
Stores:
- Custom ISO directory path (default: `/sdcard/isodrive/`)
- Custom ISO directory path (default: `/sdcard/isodroid/`)
### SharedPreferences

View File

@@ -16,7 +16,7 @@ Requirements:
* Android 8.0+ (API 26)
Usage:
1. Place your ISO/IMG files in /sdcard/isodrive/
1. Place your ISO/IMG files in /sdcard/isodroid/
2. Select an ISO/IMG file from the list
3. Choose mount options (Mass Storage or CD-ROM)
4. Tap Mount