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