working poc
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
|
app/src/main/assets/bin/isodrive
|
||||||
|
|||||||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ISO Droid
|
||||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/deploymentTargetSelector.xml
generated
Normal file
10
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.idea/gradle.xml
generated
Normal file
19
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/markdown.xml
generated
Normal file
8
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="previewPanelProviderInfo">
|
||||||
|
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/misc.xml
generated
Normal file
9
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/runConfigurations.xml
generated
Normal file
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/vcs.xml
generated
Normal file
7
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/ref-repo" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "sh.sar.isodroid"
|
namespace = "sh.sar.isodroid"
|
||||||
compileSdk {
|
compileSdk = 36
|
||||||
version = release(36)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "sh.sar.isodroid"
|
applicationId = "sh.sar.isodroid"
|
||||||
minSdk = 30
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
@@ -35,13 +34,38 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
|
|
||||||
|
// Compose
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
// libsu for root access
|
||||||
|
implementation(libs.libsu.core)
|
||||||
|
implementation(libs.libsu.service)
|
||||||
|
|
||||||
|
// DataStore for preferences
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,28 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Storage permissions -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
|
||||||
|
<!-- Query for root shell packages -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
</intent>
|
||||||
|
<package android:name="com.topjohnwu.magisk" />
|
||||||
|
<package android:name="me.weishu.kernelsu" />
|
||||||
|
<package android:name="me.bmax.apatch" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".ISODroidApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
@@ -10,6 +31,19 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ISODroid" />
|
android:theme="@style/Theme.ISODroid"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
</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">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme for Compose (night) -->
|
||||||
<style name="Theme.ISODroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.ISODroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<!-- Primary brand color. -->
|
<!-- Use Material 3 dynamic colors on Android 12+ -->
|
||||||
<item name="colorPrimary">@color/purple_200</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="colorOnPrimary">@color/black</item>
|
|
||||||
<!-- Secondary brand color. -->
|
|
||||||
<item name="colorSecondary">@color/teal_200</item>
|
|
||||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
|
||||||
<!-- Status bar color. -->
|
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme for Compose -->
|
||||||
<style name="Theme.ISODroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.ISODroid" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<!-- Primary brand color. -->
|
<!-- Use Material 3 dynamic colors on Android 12+ -->
|
||||||
<item name="colorPrimary">@color/purple_500</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="colorOnPrimary">@color/white</item>
|
|
||||||
<!-- Secondary brand color. -->
|
|
||||||
<item name="colorSecondary">@color/teal_200</item>
|
|
||||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
|
||||||
<item name="colorOnSecondary">@color/black</item>
|
|
||||||
<!-- Status bar color. -->
|
|
||||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
}
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ espressoCore = "3.7.0"
|
|||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
|
|
||||||
|
# Compose
|
||||||
|
composeBom = "2024.02.00"
|
||||||
|
activityCompose = "1.9.0"
|
||||||
|
lifecycleViewmodelCompose = "2.8.0"
|
||||||
|
navigationCompose = "2.7.7"
|
||||||
|
|
||||||
|
# libsu
|
||||||
|
libsu = "6.0.0"
|
||||||
|
|
||||||
|
# DataStore
|
||||||
|
datastorePreferences = "1.1.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
@@ -16,7 +28,27 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
|
|||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
|
||||||
|
# Compose
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|
||||||
|
# libsu
|
||||||
|
libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" }
|
||||||
|
libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" }
|
||||||
|
|
||||||
|
# DataStore
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://jitpack.io") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user