diff --git a/.gitignore b/.gitignore index aa724b7..102a551 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +app/src/main/assets/bin/isodrive diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..1fc89bf --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +ISO Droid \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..581f383 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ece3fa3..12ff0b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,17 +1,16 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { namespace = "sh.sar.isodroid" - compileSdk { - version = release(36) - } + compileSdk = 36 defaultConfig { applicationId = "sh.sar.isodroid" - minSdk = 30 + minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -35,13 +34,38 @@ android { kotlinOptions { jvmTarget = "11" } + buildFeatures { + compose = true + buildConfig = true + } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + debugImplementation(libs.androidx.compose.ui.tooling) + + // libsu for root access + implementation(libs.libsu.core) + implementation(libs.libsu.service) + + // DataStore for preferences + implementation(libs.androidx.datastore.preferences) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 416756e..7bc8cd5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,28 @@ + + + + + + + + + + + + + + + + + android:theme="@style/Theme.ISODroid" + tools:targetApi="31"> - \ No newline at end of file + + + + + + + + + + diff --git a/app/src/main/java/sh/sar/isodroid/ISODroidApp.kt b/app/src/main/java/sh/sar/isodroid/ISODroidApp.kt new file mode 100644 index 0000000..46957ff --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ISODroidApp.kt @@ -0,0 +1,31 @@ +package sh.sar.isodroid + +import android.app.Application +import com.topjohnwu.superuser.Shell + +class ISODroidApp : Application() { + + override fun onCreate() { + super.onCreate() + + // Initialize libsu Shell + Shell.enableVerboseLogging = BuildConfig.DEBUG + Shell.setDefaultBuilder( + Shell.Builder.create() + .setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_REDIRECT_STDERR) + .setTimeout(10) + ) + } + + companion object { + init { + // Set settings before the main shell can be created + Shell.enableVerboseLogging = true + Shell.setDefaultBuilder( + Shell.Builder.create() + .setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_REDIRECT_STDERR) + .setTimeout(10) + ) + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/MainActivity.kt b/app/src/main/java/sh/sar/isodroid/MainActivity.kt new file mode 100644 index 0000000..a2b1f83 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/MainActivity.kt @@ -0,0 +1,134 @@ +package sh.sar.isodroid + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import sh.sar.isodroid.ui.screens.MainScreen +import sh.sar.isodroid.ui.screens.SettingsScreen +import sh.sar.isodroid.ui.theme.ISODroidTheme +import sh.sar.isodroid.viewmodel.MainViewModel + +class MainActivity : ComponentActivity() { + + private lateinit var viewModel: MainViewModel + private var hasStoragePermission by mutableStateOf(false) + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + hasStoragePermission = permissions.values.all { it } + if (hasStoragePermission) { + viewModel.refresh() + } + } + + private val manageStorageLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + true + } + if (hasStoragePermission) { + viewModel.refresh() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + viewModel = ViewModelProvider(this)[MainViewModel::class.java] + + checkAndRequestPermissions() + + setContent { + ISODroidTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ISODroidNavHost(viewModel = viewModel) + } + } + } + } + + private fun checkAndRequestPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!Environment.isExternalStorageManager()) { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = Uri.parse("package:$packageName") + } + manageStorageLauncher.launch(intent) + } else { + hasStoragePermission = true + } + } else { + val permissions = arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + + val needsPermission = permissions.any { + ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED + } + + if (needsPermission) { + requestPermissionLauncher.launch(permissions) + } else { + hasStoragePermission = true + } + } + } +} + +@Composable +fun ISODroidNavHost(viewModel: MainViewModel) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = "main" + ) { + composable("main") { + MainScreen( + viewModel = viewModel, + onNavigateToSettings = { + navController.navigate("settings") + } + ) + } + composable("settings") { + SettingsScreen( + viewModel = viewModel, + onNavigateBack = { + navController.popBackStack() + } + ) + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/data/IsoFile.kt b/app/src/main/java/sh/sar/isodroid/data/IsoFile.kt new file mode 100644 index 0000000..75bc954 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/data/IsoFile.kt @@ -0,0 +1,32 @@ +package sh.sar.isodroid.data + +import java.io.File + +data class IsoFile( + val path: String, + val name: String, + val size: Long +) { + companion object { + fun fromFile(file: File): IsoFile { + return IsoFile( + path = file.absolutePath, + name = file.name, + size = file.length() + ) + } + } + + val formattedSize: String + get() { + val kb = size / 1024.0 + val mb = kb / 1024.0 + val gb = mb / 1024.0 + return when { + gb >= 1.0 -> String.format("%.2f GB", gb) + mb >= 1.0 -> String.format("%.2f MB", mb) + kb >= 1.0 -> String.format("%.2f KB", kb) + else -> "$size B" + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/data/MountOptions.kt b/app/src/main/java/sh/sar/isodroid/data/MountOptions.kt new file mode 100644 index 0000000..1eb023b --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/data/MountOptions.kt @@ -0,0 +1,23 @@ +package sh.sar.isodroid.data + +data class MountOptions( + val readOnly: Boolean = true, + val cdrom: Boolean = false, + val useConfigfs: Boolean = true +) { + fun toCommandArgs(): List { + val args = mutableListOf() + if (!readOnly) { + args.add("-rw") + } + if (cdrom) { + args.add("-cdrom") + } + if (useConfigfs) { + args.add("-configfs") + } else { + args.add("-usbgadget") + } + return args + } +} diff --git a/app/src/main/java/sh/sar/isodroid/data/MountStatus.kt b/app/src/main/java/sh/sar/isodroid/data/MountStatus.kt new file mode 100644 index 0000000..f169b9b --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/data/MountStatus.kt @@ -0,0 +1,18 @@ +package sh.sar.isodroid.data + +enum class MountType { + MASS_STORAGE, + CDROM, + UNKNOWN +} + +data class MountStatus( + val mounted: Boolean, + val path: String? = null, + val type: MountType = MountType.UNKNOWN, + val readOnly: Boolean = true +) { + companion object { + val UNMOUNTED = MountStatus(mounted = false) + } +} diff --git a/app/src/main/java/sh/sar/isodroid/isodrive/IsoDriveManager.kt b/app/src/main/java/sh/sar/isodroid/isodrive/IsoDriveManager.kt new file mode 100644 index 0000000..8c6c844 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/isodrive/IsoDriveManager.kt @@ -0,0 +1,273 @@ +package sh.sar.isodroid.isodrive + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import sh.sar.isodroid.data.MountOptions +import sh.sar.isodroid.data.MountStatus +import sh.sar.isodroid.data.MountType +import sh.sar.isodroid.root.RootManager +import java.io.File + +class IsoDriveManager(private val context: Context) { + + private val binaryName = "isodrive" + private var binaryPath: String? = null + + suspend fun initialize(): Boolean = withContext(Dispatchers.IO) { + extractBinary() + } + + private suspend fun extractBinary(): Boolean = withContext(Dispatchers.IO) { + try { + val abi = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: return@withContext false + val assetPath = when { + abi.contains("arm64") -> "bin/arm64-v8a/$binaryName" + abi.contains("armeabi") -> "bin/armeabi-v7a/$binaryName" + abi.contains("x86_64") -> "bin/x86_64/$binaryName" + abi.contains("x86") -> "bin/x86/$binaryName" + else -> return@withContext false + } + + val targetDir = File(context.filesDir, "bin") + if (!targetDir.exists()) { + targetDir.mkdirs() + } + + val targetFile = File(targetDir, binaryName) + + // Check if binary already exists + if (targetFile.exists()) { + binaryPath = targetFile.absolutePath + return@withContext true + } + + // Extract binary from assets + try { + context.assets.open(assetPath).use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + targetFile.setExecutable(true, false) + binaryPath = targetFile.absolutePath + true + } catch (e: Exception) { + // Binary not bundled yet, will use system isodrive if available + checkSystemBinary() + } + } catch (e: Exception) { + false + } + } + + private suspend fun checkSystemBinary(): Boolean = withContext(Dispatchers.IO) { + // Check common locations first (more reliable than 'which') + val commonPaths = listOf( + "/data/adb/modules/isodrive-magisk/system/bin/isodrive", + "/data/adb/modules/isodrive/system/bin/isodrive", + "/system/bin/isodrive", + "/system/xbin/isodrive", + "/data/local/tmp/isodrive", + "/vendor/bin/isodrive" + ) + for (path in commonPaths) { + val checkResult = RootManager.executeCommand("test -f $path && echo exists") + if (checkResult.success && checkResult.output.contains("exists")) { + binaryPath = path + return@withContext true + } + } + + // Try 'which' as fallback + val result = RootManager.executeCommand("which isodrive 2>/dev/null") + if (result.success && result.output.isNotBlank()) { + binaryPath = result.output.trim() + return@withContext true + } + + false + } + + suspend fun isSupported(): SupportStatus = withContext(Dispatchers.IO) { + // First check if binary is available + if (binaryPath == null && !extractBinary()) { + return@withContext SupportStatus.NO_BINARY + } + + // Try to mount configfs if not already mounted + RootManager.executeCommand("mount -t configfs none /sys/kernel/config 2>/dev/null") + + // Check configfs support - look for usb_gadget directory + val configfsCheck = RootManager.executeCommand( + "ls /sys/kernel/config/usb_gadget/ 2>/dev/null" + ) + if (configfsCheck.success && configfsCheck.output.isNotBlank()) { + return@withContext SupportStatus.CONFIGFS_SUPPORTED + } + + // Alternative configfs check - check if configfs is mounted at all + val configfsMountCheck = RootManager.executeCommand( + "mount | grep configfs" + ) + if (configfsMountCheck.success && configfsMountCheck.output.contains("configfs")) { + // configfs is mounted, check for usb_gadget support + val gadgetCheck = RootManager.executeCommand( + "find /sys/kernel/config -maxdepth 2 -name 'usb_gadget' -type d 2>/dev/null" + ) + if (gadgetCheck.success && gadgetCheck.output.isNotBlank()) { + return@withContext SupportStatus.CONFIGFS_SUPPORTED + } + } + + // Check sysfs/usbgadget support (legacy Android USB gadget) + val sysfsCheck = RootManager.executeCommand( + "test -d /sys/class/android_usb/android0 && echo supported" + ) + if (sysfsCheck.success && sysfsCheck.output.contains("supported")) { + return@withContext SupportStatus.SYSFS_SUPPORTED + } + + // If we have the binary, assume the user knows their device supports it + // Let isodrive itself determine support at mount time + if (binaryPath != null) { + return@withContext SupportStatus.CONFIGFS_SUPPORTED + } + + SupportStatus.NOT_SUPPORTED + } + + suspend fun mount(isoPath: String, options: MountOptions): MountResult = withContext(Dispatchers.IO) { + if (binaryPath == null) { + return@withContext MountResult( + success = false, + message = "isodrive binary not found" + ) + } + + // Validate file exists + val fileCheck = RootManager.executeCommand("test -f \"$isoPath\" && echo exists") + if (!fileCheck.success || !fileCheck.output.contains("exists")) { + return@withContext MountResult( + success = false, + message = "File not found: $isoPath" + ) + } + + // Validate options + if (options.cdrom && !options.readOnly) { + return@withContext MountResult( + success = false, + message = "CD-ROM mode requires read-only" + ) + } + + // Build command + val args = options.toCommandArgs().joinToString(" ") + val command = "$binaryPath \"$isoPath\" $args" + + val result = RootManager.executeCommand(command) + + if (result.success) { + MountResult( + success = true, + message = "Mounted successfully" + ) + } else { + MountResult( + success = false, + message = result.error.ifBlank { result.output.ifBlank { "Mount failed with exit code ${result.exitCode}" } } + ) + } + } + + suspend fun unmount(): MountResult = withContext(Dispatchers.IO) { + if (binaryPath == null) { + return@withContext MountResult( + success = false, + message = "isodrive binary not found" + ) + } + + // Running isodrive without arguments unmounts + val result = RootManager.executeCommand(binaryPath!!) + + MountResult( + success = result.success || result.output.contains("Usage"), + message = if (result.success || result.output.contains("Usage")) "Unmounted successfully" else result.error.ifBlank { "Unmount failed" } + ) + } + + suspend fun getStatus(): MountStatus = withContext(Dispatchers.IO) { + // Check configfs lun file + val lunFileResult = RootManager.executeCommand( + "cat /sys/kernel/config/usb_gadget/*/functions/mass_storage.0/lun.0/file 2>/dev/null" + ) + + if (lunFileResult.success && lunFileResult.output.isNotBlank()) { + val path = lunFileResult.output.trim() + if (path.isNotEmpty()) { + // Check if it's cdrom mode + val cdromResult = RootManager.executeCommand( + "cat /sys/kernel/config/usb_gadget/*/functions/mass_storage.0/lun.0/cdrom 2>/dev/null" + ) + val isCdrom = cdromResult.output.trim() == "1" + + // Check if read-only + val roResult = RootManager.executeCommand( + "cat /sys/kernel/config/usb_gadget/*/functions/mass_storage.0/lun.0/ro 2>/dev/null" + ) + val isReadOnly = roResult.output.trim() != "0" + + return@withContext MountStatus( + mounted = true, + path = path, + type = if (isCdrom) MountType.CDROM else MountType.MASS_STORAGE, + readOnly = isReadOnly + ) + } + } + + // Check sysfs method + val sysfsResult = RootManager.executeCommand( + "cat /sys/class/android_usb/android0/f_mass_storage/lun/file 2>/dev/null" + ) + + if (sysfsResult.success && sysfsResult.output.isNotBlank()) { + val path = sysfsResult.output.trim() + if (path.isNotEmpty()) { + return@withContext MountStatus( + mounted = true, + path = path, + type = MountType.MASS_STORAGE, + readOnly = true + ) + } + } + + MountStatus.UNMOUNTED + } + + companion object { + @Volatile + private var instance: IsoDriveManager? = null + + fun getInstance(context: Context): IsoDriveManager { + return instance ?: synchronized(this) { + instance ?: IsoDriveManager(context.applicationContext).also { instance = it } + } + } + } +} + +enum class SupportStatus { + CONFIGFS_SUPPORTED, + SYSFS_SUPPORTED, + NOT_SUPPORTED, + NO_BINARY +} + +data class MountResult( + val success: Boolean, + val message: String +) diff --git a/app/src/main/java/sh/sar/isodroid/root/RootManager.kt b/app/src/main/java/sh/sar/isodroid/root/RootManager.kt new file mode 100644 index 0000000..0be2918 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/root/RootManager.kt @@ -0,0 +1,54 @@ +package sh.sar.isodroid.root + +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object RootManager { + + init { + Shell.enableVerboseLogging = true + Shell.setDefaultBuilder( + Shell.Builder.create() + .setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_REDIRECT_STDERR) + .setTimeout(10) + ) + } + + suspend fun hasRoot(): Boolean = withContext(Dispatchers.IO) { + Shell.isAppGrantedRoot() == true + } + + suspend fun requestRoot(): Boolean = withContext(Dispatchers.IO) { + Shell.getShell().isRoot + } + + suspend fun executeCommand(command: String): CommandResult = withContext(Dispatchers.IO) { + val result = Shell.cmd(command).exec() + CommandResult( + success = result.isSuccess, + output = result.out.joinToString("\n"), + error = result.err.joinToString("\n"), + exitCode = result.code + ) + } + + suspend fun executeCommands(vararg commands: String): CommandResult = withContext(Dispatchers.IO) { + val result = Shell.cmd(*commands).exec() + CommandResult( + success = result.isSuccess, + output = result.out.joinToString("\n"), + error = result.err.joinToString("\n"), + exitCode = result.code + ) + } + + fun getShell(): Shell = Shell.getShell() +} + +data class CommandResult( + val success: Boolean, + val output: String, + val error: String, + val exitCode: Int +) diff --git a/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt b/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt new file mode 100644 index 0000000..b976856 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt @@ -0,0 +1,187 @@ +package sh.sar.isodroid.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.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.material.icons.Icons +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import sh.sar.isodroid.data.IsoFile +import sh.sar.isodroid.ui.theme.ImgPurple +import sh.sar.isodroid.ui.theme.IsoBlue + +@Composable +fun FileBrowser( + files: List, + currentPath: String, + onFileClick: (IsoFile) -> Unit, + onNavigateUp: () -> Unit, + canNavigateUp: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxSize()) { + // Current path header + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = currentPath, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (files.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Album, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No ISO/IMG files found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Place ISO or IMG files in this directory", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Parent directory item + if (canNavigateUp) { + item { + FileItem( + name = "..", + size = "", + isDirectory = true, + onClick = onNavigateUp + ) + } + } + + items(files) { file -> + FileItem( + name = file.name, + size = file.formattedSize, + isIso = file.name.lowercase().endsWith(".iso"), + onClick = { onFileClick(file) } + ) + } + } + } + } +} + +@Composable +private fun FileItem( + name: String, + size: String, + isDirectory: Boolean = false, + isIso: Boolean = true, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = when { + isDirectory -> Icons.Default.Folder + isIso -> Icons.Default.Album + else -> Icons.Default.InsertDriveFile + }, + contentDescription = null, + tint = when { + isDirectory -> MaterialTheme.colorScheme.primary + isIso -> IsoBlue + else -> ImgPurple + }, + modifier = Modifier.size(40.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (size.isNotEmpty()) { + Text( + text = size, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/components/MountDialog.kt b/app/src/main/java/sh/sar/isodroid/ui/components/MountDialog.kt new file mode 100644 index 0000000..40c5f39 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/components/MountDialog.kt @@ -0,0 +1,190 @@ +package sh.sar.isodroid.ui.components + +import androidx.compose.foundation.layout.Arrangement +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.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import sh.sar.isodroid.data.IsoFile +import sh.sar.isodroid.data.MountOptions + +@Composable +fun MountDialog( + file: IsoFile, + onDismiss: () -> Unit, + onConfirm: (MountOptions) -> Unit +) { + var readOnly by remember { mutableStateOf(true) } + var cdromMode by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Mount Options", + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column { + Text( + text = file.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = file.formattedSize, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Mount type selection + Text( + text = "Mount Type", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + + Column(Modifier.selectableGroup()) { + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = !cdromMode, + onClick = { cdromMode = false }, + role = Role.RadioButton + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = !cdromMode, + onClick = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "Mass Storage", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Standard USB drive mode", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = cdromMode, + onClick = { + cdromMode = true + readOnly = true // CD-ROM must be read-only + }, + role = Role.RadioButton + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = cdromMode, + onClick = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "CD-ROM", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Emulate optical drive (read-only)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Read-only toggle (disabled for CD-ROM mode) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Read-Only", + style = MaterialTheme.typography.bodyLarge, + color = if (cdromMode) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else + MaterialTheme.colorScheme.onSurface + ) + Text( + text = if (cdromMode) "Required for CD-ROM mode" else "Prevent write operations", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (cdromMode) 0.38f else 0.7f + ) + ) + } + Switch( + checked = readOnly, + onCheckedChange = { if (!cdromMode) readOnly = it }, + enabled = !cdromMode + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onConfirm( + MountOptions( + readOnly = readOnly, + cdrom = cdromMode + ) + ) + } + ) { + Text("Mount") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/components/StatusCard.kt b/app/src/main/java/sh/sar/isodroid/ui/components/StatusCard.kt new file mode 100644 index 0000000..2e70e56 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/components/StatusCard.kt @@ -0,0 +1,141 @@ +package sh.sar.isodroid.ui.components + +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Usb +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import sh.sar.isodroid.data.MountStatus +import sh.sar.isodroid.data.MountType +import sh.sar.isodroid.ui.theme.ErrorRed +import sh.sar.isodroid.ui.theme.MountedGreen +import sh.sar.isodroid.ui.theme.UnmountedGray + +@Composable +fun StatusCard( + mountStatus: MountStatus, + rootAvailable: Boolean, + deviceSupported: Boolean, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = when { + !rootAvailable -> MaterialTheme.colorScheme.errorContainer + !deviceSupported -> MaterialTheme.colorScheme.errorContainer + mountStatus.mounted -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = when { + !rootAvailable || !deviceSupported -> Icons.Default.Error + mountStatus.mounted -> Icons.Default.CheckCircle + else -> Icons.Default.Usb + }, + contentDescription = null, + tint = when { + !rootAvailable || !deviceSupported -> ErrorRed + mountStatus.mounted -> MountedGreen + else -> UnmountedGray + }, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = when { + !rootAvailable -> "Root Access Required" + !deviceSupported -> "Device Not Supported" + mountStatus.mounted -> "Mounted" + else -> "Not Mounted" + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + if (!rootAvailable) { + Text( + text = "Grant root access to use ISODroid", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (!deviceSupported) { + Text( + text = "USB gadget not supported on this device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + if (mountStatus.mounted) { + Icon( + imageVector = if (mountStatus.type == MountType.CDROM) + Icons.Default.Album + else + Icons.Default.Usb, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + + if (mountStatus.mounted && mountStatus.path != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = mountStatus.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Row { + Text( + text = if (mountStatus.type == MountType.CDROM) "CD-ROM" else "Mass Storage", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (mountStatus.readOnly) "Read-Only" else "Read-Write", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt b/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt new file mode 100644 index 0000000..e71a259 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt @@ -0,0 +1,176 @@ +package sh.sar.isodroid.ui.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Eject +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +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.unit.dp +import kotlinx.coroutines.launch +import sh.sar.isodroid.data.IsoFile +import sh.sar.isodroid.data.MountOptions +import sh.sar.isodroid.ui.components.FileBrowser +import sh.sar.isodroid.ui.components.MountDialog +import sh.sar.isodroid.ui.components.StatusCard +import sh.sar.isodroid.viewmodel.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + viewModel: MainViewModel, + onNavigateToSettings: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + var selectedFile by remember { mutableStateOf(null) } + var showMountDialog by remember { mutableStateOf(false) } + + // Show error messages + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearError() + } + } + + // Show success messages + LaunchedEffect(uiState.successMessage) { + uiState.successMessage?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearSuccess() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("ISODroid") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh" + ) + } + IconButton(onClick = onNavigateToSettings) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings" + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + if (uiState.mountStatus.mounted) { + ExtendedFloatingActionButton( + onClick = { + scope.launch { + viewModel.unmount() + } + }, + icon = { + Icon( + imageVector = Icons.Default.Eject, + contentDescription = null + ) + }, + text = { Text("Unmount") }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + ) { + StatusCard( + mountStatus = uiState.mountStatus, + rootAvailable = uiState.hasRoot, + deviceSupported = uiState.isSupported + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (uiState.hasRoot && uiState.isSupported) { + FileBrowser( + files = uiState.isoFiles, + currentPath = uiState.currentPath, + onFileClick = { file -> + selectedFile = file + showMountDialog = true + }, + onNavigateUp = { viewModel.navigateUp() }, + canNavigateUp = viewModel.canNavigateUp(), + modifier = Modifier.weight(1f) + ) + } + } + } + + // Mount dialog + selectedFile?.let { file -> + if (showMountDialog) { + MountDialog( + file = file, + onDismiss = { + showMountDialog = false + selectedFile = null + }, + onConfirm = { options -> + val filePath = file.path // Capture path before clearing state + showMountDialog = false + selectedFile = null + scope.launch { + viewModel.mount(filePath, options) + } + } + ) + } + } +} 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 new file mode 100644 index 0000000..a4b4f20 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt @@ -0,0 +1,227 @@ +package sh.sar.isodroid.ui.screens + +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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 +import sh.sar.isodroid.viewmodel.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: MainViewModel, + onNavigateBack: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + var showPathDialog by remember { mutableStateOf(false) } + var tempPath by remember(uiState.currentPath) { mutableStateOf(uiState.isoDirectory) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + // Storage section + SectionHeader(title = "Storage") + + SettingsItem( + icon = Icons.Default.Folder, + title = "ISO Directory", + subtitle = uiState.isoDirectory, + onClick = { showPathDialog = true } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // About section + SectionHeader(title = "About") + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "ISODroid", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Version 1.0", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Mount ISO/IMG files as USB mass storage or CD-ROM devices on rooted Android devices.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Based on isodrive by nitanmarcel", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + } + + // Path edit 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() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + viewModel.setIsoDirectory(tempPath) + showPathDialog = false + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showPathDialog = false }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) +} + +@Composable +private fun SettingsItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/theme/Color.kt b/app/src/main/java/sh/sar/isodroid/ui/theme/Color.kt new file mode 100644 index 0000000..e93b532 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/theme/Color.kt @@ -0,0 +1,22 @@ +package sh.sar.isodroid.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary colors +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +// Status colors +val MountedGreen = Color(0xFF4CAF50) +val UnmountedGray = Color(0xFF9E9E9E) +val ErrorRed = Color(0xFFF44336) +val WarningOrange = Color(0xFFFF9800) + +// File type colors +val IsoBlue = Color(0xFF2196F3) +val ImgPurple = Color(0xFF9C27B0) diff --git a/app/src/main/java/sh/sar/isodroid/ui/theme/Theme.kt b/app/src/main/java/sh/sar/isodroid/ui/theme/Theme.kt new file mode 100644 index 0000000..d726ed9 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/theme/Theme.kt @@ -0,0 +1,59 @@ +package sh.sar.isodroid.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 +) + +@Composable +fun ISODroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/theme/Type.kt b/app/src/main/java/sh/sar/isodroid/ui/theme/Type.kt new file mode 100644 index 0000000..74f2bd7 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/theme/Type.kt @@ -0,0 +1,38 @@ +package sh.sar.isodroid.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ) +) diff --git a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..c55e533 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt @@ -0,0 +1,255 @@ +package sh.sar.isodroid.viewmodel + +import android.app.Application +import android.os.Environment +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import sh.sar.isodroid.data.IsoFile +import sh.sar.isodroid.data.MountOptions +import sh.sar.isodroid.data.MountStatus +import sh.sar.isodroid.isodrive.IsoDriveManager +import sh.sar.isodroid.isodrive.SupportStatus +import sh.sar.isodroid.root.RootManager +import java.io.File + +private val Application.dataStore: DataStore by preferencesDataStore(name = "settings") + +class MainViewModel(application: Application) : AndroidViewModel(application) { + + private val isoDriveManager = IsoDriveManager.getInstance(application) + private val dataStore = application.dataStore + + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var navigationStack = mutableListOf() + + companion object { + private val KEY_ISO_DIRECTORY = stringPreferencesKey("iso_directory") + private val DEFAULT_ISO_DIRECTORY = "${Environment.getExternalStorageDirectory().absolutePath}/isodrive" + } + + init { + viewModelScope.launch { + initialize() + } + } + + private suspend fun initialize() { + _uiState.update { it.copy(isLoading = true) } + + // Load saved ISO directory + val savedDirectory = dataStore.data.map { preferences -> + preferences[KEY_ISO_DIRECTORY] ?: DEFAULT_ISO_DIRECTORY + }.first() + + _uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) } + navigationStack.add(savedDirectory) + + // Check root access + val hasRoot = RootManager.requestRoot() + _uiState.update { it.copy(hasRoot = hasRoot) } + + if (hasRoot) { + // Initialize isodrive manager + isoDriveManager.initialize() + + // Check device support + val supportStatus = isoDriveManager.isSupported() + val isSupported = supportStatus == SupportStatus.CONFIGFS_SUPPORTED || + supportStatus == SupportStatus.SYSFS_SUPPORTED + + _uiState.update { it.copy(isSupported = isSupported) } + + if (isSupported) { + // Load files and check mount status + loadFiles() + checkMountStatus() + } + } + + _uiState.update { it.copy(isLoading = false) } + } + + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + loadFiles() + checkMountStatus() + _uiState.update { it.copy(isLoading = false) } + } + } + + private suspend fun loadFiles() { + val currentPath = _uiState.value.currentPath + val directory = File(currentPath) + + // Create directory if it doesn't exist + if (!directory.exists()) { + RootManager.executeCommand("mkdir -p \"$currentPath\"") + } + + // Try multiple methods to list files + val files = loadFilesViaFind(currentPath) + ?: loadFilesViaLs(currentPath) + ?: loadFilesDirect(directory) + + _uiState.update { it.copy(isoFiles = files) } + } + + private suspend fun loadFilesViaFind(currentPath: String): List? { + // Use find command - more reliable for getting full paths + val result = RootManager.executeCommand( + "find \"$currentPath\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null" + ) + + if (!result.success || result.output.isBlank()) return null + + return result.output.lines() + .filter { it.isNotBlank() } + .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") + val size = sizeResult.output.trim().toLongOrNull() ?: 0L + IsoFile( + path = filePath.trim(), + name = name, + size = size + ) + } + .sortedBy { it.name.lowercase() } + .takeIf { it.isNotEmpty() } + } + + private suspend fun loadFilesViaLs(currentPath: String): List? { + // Simple ls command - just get filenames + val result = RootManager.executeCommand( + "ls \"$currentPath\" 2>/dev/null" + ) + + if (!result.success || result.output.isBlank()) return null + + return result.output.lines() + .filter { name -> + name.isNotBlank() && + (name.endsWith(".iso", true) || name.endsWith(".img", true)) + } + .mapNotNull { name -> + val filePath = "$currentPath/$name" + // Get file size via stat + val sizeResult = RootManager.executeCommand("stat -c %s \"$filePath\" 2>/dev/null") + val size = sizeResult.output.trim().toLongOrNull() ?: 0L + IsoFile( + path = filePath, + name = name.trim(), + size = size + ) + } + .sortedBy { it.name.lowercase() } + .takeIf { it.isNotEmpty() } + } + + private fun loadFilesDirect(directory: File): List { + // Fallback to direct file access (works if app has storage permission) + return directory.listFiles() + ?.filter { file -> + file.isFile && (file.name.endsWith(".iso", true) || + file.name.endsWith(".img", true)) + } + ?.map { IsoFile.fromFile(it) } + ?.sortedBy { it.name.lowercase() } + ?: emptyList() + } + + private suspend fun checkMountStatus() { + val status = isoDriveManager.getStatus() + _uiState.update { it.copy(mountStatus = status) } + } + + suspend fun mount(path: String, options: MountOptions) { + _uiState.update { it.copy(isLoading = true) } + + val result = isoDriveManager.mount(path, options) + + if (result.success) { + checkMountStatus() + _uiState.update { it.copy(successMessage = result.message, isLoading = false) } + } else { + _uiState.update { it.copy(errorMessage = result.message, isLoading = false) } + } + } + + suspend fun unmount() { + _uiState.update { it.copy(isLoading = true) } + + val result = isoDriveManager.unmount() + + if (result.success) { + checkMountStatus() + _uiState.update { it.copy(successMessage = result.message, isLoading = false) } + } else { + _uiState.update { it.copy(errorMessage = result.message, isLoading = false) } + } + } + + fun setIsoDirectory(path: String) { + viewModelScope.launch { + dataStore.edit { preferences -> + preferences[KEY_ISO_DIRECTORY] = path + } + navigationStack.clear() + navigationStack.add(path) + _uiState.update { it.copy(isoDirectory = path, currentPath = path) } + loadFiles() + } + } + + fun navigateUp() { + if (navigationStack.size > 1) { + navigationStack.removeAt(navigationStack.lastIndex) + val parentPath = navigationStack.last() + _uiState.update { it.copy(currentPath = parentPath) } + viewModelScope.launch { + loadFiles() + } + } + } + + fun canNavigateUp(): Boolean { + return navigationStack.size > 1 + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun clearSuccess() { + _uiState.update { it.copy(successMessage = null) } + } +} + +data class MainUiState( + val isLoading: Boolean = true, + val hasRoot: Boolean = false, + val isSupported: Boolean = false, + val mountStatus: MountStatus = MountStatus.UNMOUNTED, + val isoFiles: List = emptyList(), + val currentPath: String = "", + val isoDirectory: String = "", + val errorMessage: String? = null, + val successMessage: String? = null +) diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 1dd4288..7040ad4 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,8 @@ - - - \ No newline at end of file + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c5a0b53..5273f74 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,16 +1,8 @@ - - - \ No newline at end of file + diff --git a/build.gradle.kts b/build.gradle.kts index 922f551..5c98ad0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false -} \ No newline at end of file + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f637679..99ed04b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,18 @@ espressoCore = "3.7.0" appcompat = "1.7.1" material = "1.13.0" +# Compose +composeBom = "2024.02.00" +activityCompose = "1.9.0" +lifecycleViewmodelCompose = "2.8.0" +navigationCompose = "2.7.7" + +# libsu +libsu = "6.0.0" + +# DataStore +datastorePreferences = "1.1.1" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -16,7 +28,27 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +# Compose +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# libsu +libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } +libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" } + +# DataStore +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } - +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0165aa7..d880d57 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }