From 79ccd6753efd54483a12a4e9140fb43081bdd4f5 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Wed, 11 Mar 2026 12:42:49 +0500 Subject: [PATCH] added a file manager for directory selection --- .../sar/isodroid/ui/screens/SettingsScreen.kt | 423 ++++++++++++++++-- 1 file changed, 398 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt b/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt index a6e3643..8207a00 100644 --- a/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt @@ -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>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var showCreateFolderDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(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") } }