working poc

This commit is contained in:
2026-03-10 00:36:59 +05:00
parent 881dbfb648
commit a319a07440
34 changed files with 2067 additions and 37 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
app/src/main/assets/bin/isodrive

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
ISO Droid

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ref-repo" vcs="Git" />
</component>
</project>

View File

@@ -1,17 +1,16 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
} }
android { android {
namespace = "sh.sar.isodroid" namespace = "sh.sar.isodroid"
compileSdk { compileSdk = 36
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "sh.sar.isodroid" applicationId = "sh.sar.isodroid"
minSdk = 30 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
@@ -35,12 +34,37 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
} }
buildFeatures {
compose = true
buildConfig = true
}
} }
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) 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) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -2,7 +2,28 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Storage permissions -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Query for root shell packages -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
<package android:name="com.topjohnwu.magisk" />
<package android:name="me.weishu.kernelsu" />
<package android:name="me.bmax.apatch" />
</queries>
<application <application
android:name=".ISODroidApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -10,6 +31,19 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ISODroid" /> android:theme="@style/Theme.ISODroid"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ISODroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest> </manifest>

View File

@@ -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)
)
}
}
}

View File

@@ -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()
}
)
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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<String> {
val args = mutableListOf<String>()
if (!readOnly) {
args.add("-rw")
}
if (cdrom) {
args.add("-cdrom")
}
if (useConfigfs) {
args.add("-configfs")
} else {
args.add("-usbgadget")
}
return args
}
}

View File

@@ -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)
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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<IsoFile>,
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
)
}
}
}
}
}

View File

@@ -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")
}
}
)
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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<IsoFile?>(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)
}
}
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
)

View File

@@ -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<Preferences> 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<MainUiState> = _uiState.asStateFlow()
private var navigationStack = mutableListOf<String>()
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<IsoFile>? {
// 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<IsoFile>? {
// 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<IsoFile> {
// 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<IsoFile> = emptyList(),
val currentPath: String = "",
val isoDirectory: String = "",
val errorMessage: String? = null,
val successMessage: String? = null
)

View File

@@ -1,16 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme for Compose (night) -->
<style name="Theme.ISODroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <style name="Theme.ISODroid" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary brand color. --> <!-- Use Material 3 dynamic colors on Android 12+ -->
<item name="colorPrimary">@color/purple_200</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="colorPrimaryVariant">@color/purple_700</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style> </style>
</resources> </resources>

View File

@@ -1,16 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme for Compose -->
<style name="Theme.ISODroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> <style name="Theme.ISODroid" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Primary brand color. --> <!-- Use Material 3 dynamic colors on Android 12+ -->
<item name="colorPrimary">@color/purple_500</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="colorPrimaryVariant">@color/purple_700</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style> </style>
</resources> </resources>

View File

@@ -2,4 +2,5 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
} }

View File

@@ -8,6 +8,18 @@ espressoCore = "3.7.0"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.13.0" 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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } 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" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View File

@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }