working poc
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,28 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:name=".ISODroidApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -10,6 +31,19 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ISODroid" />
|
||||
android:theme="@style/Theme.ISODroid"
|
||||
tools:targetApi="31">
|
||||
|
||||
</manifest>
|
||||
<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>
|
||||
|
||||
31
app/src/main/java/sh/sar/isodroid/ISODroidApp.kt
Normal file
31
app/src/main/java/sh/sar/isodroid/ISODroidApp.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
app/src/main/java/sh/sar/isodroid/MainActivity.kt
Normal file
134
app/src/main/java/sh/sar/isodroid/MainActivity.kt
Normal 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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/sh/sar/isodroid/data/IsoFile.kt
Normal file
32
app/src/main/java/sh/sar/isodroid/data/IsoFile.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/sh/sar/isodroid/data/MountOptions.kt
Normal file
23
app/src/main/java/sh/sar/isodroid/data/MountOptions.kt
Normal 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
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/sh/sar/isodroid/data/MountStatus.kt
Normal file
18
app/src/main/java/sh/sar/isodroid/data/MountStatus.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
273
app/src/main/java/sh/sar/isodroid/isodrive/IsoDriveManager.kt
Normal file
273
app/src/main/java/sh/sar/isodroid/isodrive/IsoDriveManager.kt
Normal 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
|
||||
)
|
||||
54
app/src/main/java/sh/sar/isodroid/root/RootManager.kt
Normal file
54
app/src/main/java/sh/sar/isodroid/root/RootManager.kt
Normal 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
|
||||
)
|
||||
187
app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt
Normal file
187
app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
app/src/main/java/sh/sar/isodroid/ui/components/MountDialog.kt
Normal file
190
app/src/main/java/sh/sar/isodroid/ui/components/MountDialog.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
141
app/src/main/java/sh/sar/isodroid/ui/components/StatusCard.kt
Normal file
141
app/src/main/java/sh/sar/isodroid/ui/components/StatusCard.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt
Normal file
176
app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
227
app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt
Normal file
227
app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/sh/sar/isodroid/ui/theme/Color.kt
Normal file
22
app/src/main/java/sh/sar/isodroid/ui/theme/Color.kt
Normal 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)
|
||||
59
app/src/main/java/sh/sar/isodroid/ui/theme/Theme.kt
Normal file
59
app/src/main/java/sh/sar/isodroid/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
38
app/src/main/java/sh/sar/isodroid/ui/theme/Type.kt
Normal file
38
app/src/main/java/sh/sar/isodroid/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
)
|
||||
255
app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt
Normal file
255
app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt
Normal 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
|
||||
)
|
||||
@@ -1,16 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.ISODroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</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. -->
|
||||
<!-- Base application theme for Compose (night) -->
|
||||
<style name="Theme.ISODroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Use Material 3 dynamic colors on Android 12+ -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.ISODroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</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. -->
|
||||
<!-- Base application theme for Compose -->
|
||||
<style name="Theme.ISODroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Use Material 3 dynamic colors on Android 12+ -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user