39 Commits

Author SHA1 Message Date
659c79e5dd workflows
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-03-28 22:35:14 +05:00
0512662466 ignore key and release apk 2026-03-28 22:35:03 +05:00
842bb0553e build..release 2026-03-28 22:34:33 +05:00
96e5cc1213 build release using signed key 2026-03-28 22:32:02 +05:00
621618a3da update badges links 2026-03-28 08:48:12 +05:00
b6b05a71de update badges 2026-03-28 08:42:53 +05:00
72645c054a add badges 2026-03-28 08:35:02 +05:00
676df8e8b3 update docs 2026-03-14 01:34:42 +05:00
b15c7c8213 update docs 2026-03-14 01:30:09 +05:00
7c2f06bceb update docs 2026-03-14 01:18:21 +05:00
4a0817b222 update docs 2026-03-14 01:13:03 +05:00
2b34d3f2ac update docs 2026-03-14 01:11:26 +05:00
085552b759 update docs 2026-03-14 01:06:10 +05:00
4379656f16 update docs 2026-03-14 01:01:12 +05:00
4787302f25 update docs 2026-03-14 00:56:57 +05:00
0ec26cac29 update docs 2026-03-14 00:19:12 +05:00
e9dfbce049 Version 1.7 2026-03-13 23:46:48 +05:00
ccb2af558b Prep for compatbility for edgeing 2026-03-13 23:44:20 +05:00
d0817240ec fix deprecation warning by using newer API 2026-03-13 23:18:00 +05:00
b1abee3579 rtl support and fix some non critical warnings 2026-03-13 23:17:11 +05:00
148b494926 delete isodrive download and build scripts, they are build from gradle now 2026-03-13 22:59:20 +05:00
cdba2582fa more retarded shit android studio likes todo for no reason 2026-03-13 22:58:39 +05:00
3068939d93 build isodrive is missing during apk build 2026-03-13 22:58:15 +05:00
dc6f72bcc5 remove dependence on system isodrive bin, instead use bundled bin 2026-03-13 22:57:53 +05:00
804e4c3ae3 new version: 1.6 2026-03-13 01:28:33 +05:00
b9a95bd12d reorgnize images 2026-03-13 01:28:09 +05:00
32406e335a remove deprecated: 'static field FLAG_REDIRECT_STDERR: Int' 2026-03-13 01:19:04 +05:00
235053eba6 no clue tf this is 2026-03-13 01:06:01 +05:00
8e3b29b5df Add USB services restart warning toggle 2026-03-13 01:05:45 +05:00
22d729ce53 Add USB services restart warning 2026-03-13 01:02:48 +05:00
06b67d64c7 add Caution about root app 2026-03-13 00:42:50 +05:00
f15882aea2 disable logcat on release build 2026-03-13 00:32:13 +05:00
f3dc0b65d6 attempt to prevent shell escape 2026-03-13 00:25:09 +05:00
4b22871ab4 disable logcat on release builds, add shellexcape prevention fun 2026-03-13 00:24:50 +05:00
d15b56c9c0 AI disclaimer docs 2026-03-12 23:43:12 +05:00
88ed16f89e AI disclaimer docs 2026-03-12 23:40:27 +05:00
71842b01d4 AI disclaimer docs 2026-03-12 23:35:52 +05:00
fb74e3663d AI disclaimer docs 2026-03-12 23:29:25 +05:00
f031a96193 .idea 2026-03-12 18:01:09 +05:00
41 changed files with 900 additions and 319 deletions

View File

@@ -0,0 +1,3 @@
KEYSTORE_PASSWORD=your_keystore_password_here
KEY_ALIAS=your_key_alias_here
KEY_PASSWORD=your_key_password_here

2
.build/release/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
release/

View File

@@ -0,0 +1,11 @@
services:
release:
# image: git.shihaam.dev/dockerfiles/android-builder
image: git.shihaam.dev/dockerfiles/runners/gradle
hostname: isodroid
network_mode: host
env_file: .env
volumes:
- ./release:/release
- ../../:/source
- ./cache:/root/.gradle

View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
TAG="$1"
TITLE="$2"
NOTES_FILE="$3"
ASSET_PATH="$4"
API_URL="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases"
# Create release
RELEASE_RESPONSE=$(curl -s -X POST "$API_URL" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"${TITLE}\",
\"body\": $(jq -Rs . < "$NOTES_FILE")
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
exit 1
fi
echo "Created release with ID: $RELEASE_ID"
# Upload asset
ASSET_NAME=$(basename "$ASSET_PATH")
UPLOAD_URL="${API_URL}/${RELEASE_ID}/assets?name=${ASSET_NAME}"
curl -s -X POST "$UPLOAD_URL" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${ASSET_PATH}"
echo "Uploaded asset: $ASSET_NAME"

View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -e
VERSION="${1#v}"
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
FASTLANE_DIR="fastlane/metadata/android/en-US/changelogs"
mkdir -p "$FASTLANE_DIR"
awk -v ver="$VERSION" '
BEGIN { found=0 }
/^## \[/ {
if (found) exit
if ($0 ~ "\\[" ver "\\]") { found=1; next }
}
found { print }
' CHANGELOG.md | tee release_notes.md > "$FASTLANE_DIR/${VERSION_CODE}.txt"

View File

@@ -0,0 +1,30 @@
name: Auto Tag on Version Change
on:
push:
branches:
- main
jobs:
check-version:
runs-on: docker-compose
steps:
- name: Checkout source code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_GITEA }}
- name: Create tag if version changed
run: |
VERSION=$(grep 'versionName = ' app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
echo "Current version: $VERSION"
if git tag -l | grep -q "^v${VERSION}$"; then
echo "Tag v${VERSION} already exists, skipping"
else
git tag "v${VERSION}"
git push origin "v${VERSION}"
echo "Created and pushed tag v${VERSION}"
fi

View File

@@ -0,0 +1,38 @@
name: Build and Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: docker-compose
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup keystore and environment
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/key.jks
echo "${{ secrets.DOTENV_BASE64 }}" | base64 -d > .build/release/.env
- name: Build APK
working-directory: .build/release
run: docker compose run --rm release
- name: Extract changelog
run: bash .build/release/extract-changelog.sh ${{ gitea.ref_name }}
- name: Create Gitea Release
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
run: |
bash .build/release/create-release.sh \
"${{ gitea.ref_name }}" \
"ISODroid ${{ gitea.ref_name }}" \
"release_notes.md" \
".build/release/release/ISODroid-${{ gitea.ref_name }}.apk"

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ local.properties
app/release/
app/debug/
app/key.jks
.build/release/release/*.apk

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "isodrive"]
path = isodrive
url = https://git.shihaam.dev/shihaam/isodrive

1
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/isodrive" vcs="Git" />
</component>
</project>

View File

@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7] - 2026-03-13
### Changed
- Always use app-bundled isodrive binary instead of system binary
- Prep for edge-to-edge compatibility
## [1.6] - 2026-03-13
### Added
- Disclaimer screen in welcome wizard about user responsibility
- USB services restart warning dialog before mount/unmount operations
- Toggle in Settings to enable/disable USB restart warning
### Fixed
- Prevent shell escape exploits in shell commands
- Disable logcat on release builds
## [1.5] - 2026-03-12
### Added

View File

@@ -1,12 +1,26 @@
# ISO Droid
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
![Android 8.0+](https://img.shields.io/badge/Android-8.0+-3DDC84?logo=android&logoColor=white)
![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?logo=kotlin&logoColor=white)
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white)
[![Root Required](https://img.shields.io/badge/Root-Required-red.svg)](docs/DISCLAIMER.md)
![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg)
[![Gitea Release](https://img.shields.io/gitea/v/release/shihaam/ISODroid?gitea_url=https://git.shihaam.dev&logo=gitea)](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
> **Note:** This app requires root access and was developed with AI assistance (Claude).
> I use it on my own devices, but as with any root tool, understand what you're running and keep backups.
>
> See [full disclaimer](docs/DISCLAIMER.md) | [Report issues](https://git.sargit.com/sargit/ISODroid/issues)
Android app for mounting ISO/IMG files as USB mass storage or CD-ROM devices on rooted Android devices.
## Screenshots
| OS images listing | Mounted Status | Mount Options Dialog | Create IMG Dialog | Download OS ISOs |
| OS images listing | Mount Options Dialog | Mounted Status | Create IMG Dialog | Download OS ISOs |
|:--:|:--:|:--:|:--:|:--:|
| ![File Browser](docs/screenshots/list_images_home.jpg) | ![Mounted Status](docs/screenshots/mounted_status_home.jpg) | ![Mount Options](docs/screenshots/mount_options_dialogbox.jpg) | ![Create IMG](docs/screenshots/create_img_digalogbox.jpg) | ![Download ISOs](docs/screenshots/list_listing_download.jpg) |
| ![File Browser](docs/screenshots/list_images_home.jpg) | ![Mount Options](docs/screenshots/mount_options_dialogbox.jpg) | ![Mounted Status](docs/screenshots/mounted_status_home.jpg) | ![Create IMG](docs/screenshots/create_img_digalogbox.jpg) | ![Download ISOs](docs/screenshots/list_listing_download.jpg) |
## Features
@@ -27,8 +41,9 @@ Android app for mounting ISO/IMG files as USB mass storage or CD-ROM devices on
## Download
- **F-Droid**: *Coming soon*
- **Gitea Releases**: [Download latest APK](https://git.sargit.com/sargit/ISODroid/releases)
| F-Droid | Gitea Releases |
|:--:|:--:|
| <!-- [![Get it on F-Droid](https://f-droid.org/badge/get-it-on.png)](https://f-droid.org/en/packages/sh.sar.isodroid) --> [Coming Soon](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/34539) | [Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest) |
## Installation
@@ -37,8 +52,6 @@ Android app for mounting ISO/IMG files as USB mass storage or CD-ROM devices on
3. Grant root access when prompted
4. Place your ISO/IMG files in `/sdcard/isodroid/` (or configure a different directory in settings)
> **Note**: The app includes a bundled `isodrive` binary. No additional setup required!
## Usage
1. Open ISO Droid
@@ -59,11 +72,24 @@ Android app for mounting ISO/IMG files as USB mass storage or CD-ROM devices on
- **[Building & Technical Details](docs/BUILDING.md)** - How to build from source, architecture overview, and technical documentation
## Credits
## Credits & Inspiration
Around 2014/2015 I rooted my first phone and went down the rabbit hole of what you could do with a rooted Android.
That's when I found [DriveDroid](https://softwarebakery.com/projects/drivedroid) ([archive](https://web.archive.org/web/20160901000000*/https://softwarebakery.com/projects/drivedroid)) by Software Bakery. Teenage me thought it was the coolest thing ever.
Fast forward a 10 years: DriveDroid is no longer maintained, 404 on Play Store, and even if you find an APK ([Kali NetHunter App Store](https://store.nethunter.com/packages/com.softwarebakery.drivedroid/) keeps one) it does not work on modern Android.
As someone who gives tech support often to relatives and friends, I've wanted something like it for years. Having Linux to chroot or HBCD for Windows password reset at anytime without having to carry around USB drives is very useful for me.
Then I found [isodrive](https://github.com/nitanmarcel/isodrive) by nitanmarcel ([mirror](https://git.shihaam.dev/shihaam/isodrive)). I tried the Magisk module and Termux, it worked. So here we are.
ISO Droid is basically me chasing that teenage nostalgia and trying to make something simple that just works.
The name comes from **iso**drive + Drive**Droid** = **ISO Droid**.
Special thanks to:
- [isodrive](https://github.com/nitanmarcel/isodrive) by nitanmarcel - The CLI tool that powers ISO Droid
- [ISODriveUT](https://github.com/fredldotme/ISODriveUT) by fredldotme - Original inspiration for isodrive
- OS icons from [Simple Icons](https://simpleicons.org/) and [SVG Repo](https://www.svgrepo.com/)
- [DriveDroid](https://softwarebakery.com/projects/drivedroid) by Software Bakery - where it all started for me
- [Simple Icons](https://simpleicons.org/) and [SVG Repo](https://www.svgrepo.com/) for OS icons
## License

View File

@@ -1,9 +1,127 @@
import javax.inject.Inject
import org.gradle.process.ExecOperations
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
// Build isodrive native binary from submodule (only if binaries are missing)
abstract class BuildIsodriveTask @Inject constructor(
private val execOps: ExecOperations
) : DefaultTask() {
@get:InputDirectory
abstract val isodriveDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun build() {
val isodrivePath = isodriveDir.get().asFile
val outputPath = outputDir.get().asFile
if (!isodrivePath.resolve("src").exists()) {
throw GradleException("isodrive submodule not initialized. Run: git submodule update --init")
}
val sourceFiles = listOf(
"src/util.cpp",
"src/configfsisomanager.cpp",
"src/androidusbisomanager.cpp",
"src/main.cpp"
)
val srcs = sourceFiles.joinToString(" ") { "$isodrivePath/$it" }
val cflags = "-I$isodrivePath/src/include -static-libstdc++ -Os -s"
val compilers = mapOf(
"arm64-v8a" to "aarch64-linux-android26-clang++",
"armeabi-v7a" to "armv7a-linux-androideabi26-clang++",
"x86_64" to "x86_64-linux-android26-clang++",
"x86" to "i686-linux-android26-clang++"
)
val useNixShell = File("/etc/NIXOS").exists() ||
Runtime.getRuntime().exec(arrayOf("which", "nix-shell")).waitFor() == 0
if (useNixShell) {
println("NixOS detected, using nix-shell for NDK...")
val buildScript = compilers.entries.joinToString("\n") { (arch, compiler) ->
val archDir = outputPath.resolve(arch)
val output = archDir.resolve("isodrive")
if (output.exists()) {
"echo 'isodrive for $arch already exists, skipping'"
} else {
"""
mkdir -p "$archDir"
echo "Building isodrive for $arch..."
"${'$'}TOOLCHAIN/$compiler" $cflags $srcs -o "$output"
""".trimIndent()
}
}
execOps.exec {
environment("NIXPKGS_ALLOW_UNFREE", "1")
commandLine("nix-shell", "-p", "androidenv.androidPkgs.ndk-bundle", "--run", """
SDK_ROOT=${'$'}(find /nix/store -maxdepth 1 -name "*android-sdk-ndk*" -type d 2>/dev/null | head -1)
NDK=${'$'}(ls -d "${'$'}SDK_ROOT/libexec/android-sdk/ndk/"* | head -1)
TOOLCHAIN="${'$'}NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
echo "Using NDK: ${'$'}NDK"
$buildScript
echo "Done building isodrive"
""".trimIndent())
}
} else {
val ndkDir = listOfNotNull(
System.getenv("ANDROID_NDK_HOME"),
System.getenv("ANDROID_NDK")
).firstOrNull { File(it).exists() }
?: throw GradleException("Android NDK not found. Set ANDROID_NDK_HOME or install NDK via SDK Manager.")
val toolchain = "$ndkDir/toolchains/llvm/prebuilt/linux-x86_64/bin"
if (!File(toolchain).exists()) {
throw GradleException("NDK toolchain not found at: $toolchain")
}
compilers.forEach { (arch, compiler) ->
val archDir = outputPath.resolve(arch)
val output = archDir.resolve("isodrive")
if (output.exists()) {
println("isodrive for $arch already exists, skipping")
return@forEach
}
archDir.mkdirs()
println("Building isodrive for $arch...")
execOps.exec {
commandLine("sh", "-c", "$toolchain/$compiler $cflags $srcs -o $output")
}
}
println("Done building isodrive")
}
}
}
val buildIsodrive by tasks.registering(BuildIsodriveTask::class) {
group = "native"
description = "Build isodrive binary for all Android architectures"
isodriveDir.set(rootProject.file("isodrive"))
outputDir.set(file("src/main/assets/bin"))
val architectures = listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
val outDir = file("src/main/assets/bin")
onlyIf { !architectures.all { arch -> outDir.resolve("$arch/isodrive").exists() } }
}
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("Assets") }.configureEach {
dependsOn(buildIsodrive)
}
android {
namespace = "sh.sar.isodroid"
compileSdk = 36
@@ -12,14 +130,24 @@ android {
applicationId = "sh.sar.isodroid"
minSdk = 26
targetSdk = 36
versionCode = 5
versionName = "1.5"
versionCode = 7
versionName = "1.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
storeFile = file("key.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),

View File

@@ -25,7 +25,7 @@ class ISODroidApp : Application() {
companion object {
init {
// Set settings before the main shell can be created
Shell.enableVerboseLogging = true
Shell.enableVerboseLogging = BuildConfig.DEBUG
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER)

View File

@@ -58,42 +58,14 @@ class IsoDriveManager(private val context: Context) {
binaryPath = targetFile.absolutePath
true
} catch (e: Exception) {
// Binary not bundled yet, will use system isodrive if available
checkSystemBinary()
// Binary extraction failed
false
}
} 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()) {
@@ -150,8 +122,9 @@ class IsoDriveManager(private val context: Context) {
)
}
// Validate file exists
val fileCheck = RootManager.executeCommand("test -f \"$isoPath\" && echo exists")
// Validate file exists using shell-safe escaping
val safePath = RootManager.shellEscape(isoPath)
val fileCheck = RootManager.executeCommand("test -f $safePath && echo exists")
if (!fileCheck.success || !fileCheck.output.contains("exists")) {
return@withContext MountResult(
success = false,
@@ -167,9 +140,9 @@ class IsoDriveManager(private val context: Context) {
)
}
// Build command
// Build command with safe path escaping
val args = options.toCommandArgs().joinToString(" ")
val command = "$binaryPath \"$isoPath\" $args"
val command = "$binaryPath $safePath $args"
val result = RootManager.executeCommand(command)

View File

@@ -6,16 +6,29 @@
package sh.sar.isodroid.root
import com.topjohnwu.superuser.Shell
import sh.sar.isodroid.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object RootManager {
/**
* Escapes a string for safe use in shell commands.
* Uses single quotes and escapes any single quotes within the string.
* This prevents command injection via $(), ``, ;, &&, ||, etc.
*/
fun shellEscape(s: String): String {
// Single quotes prevent all shell interpretation except for single quotes themselves
// To include a single quote, we end the single-quoted string, add an escaped single quote, and start a new single-quoted string
// Example: "test'file" becomes 'test'\''file'
return "'" + s.replace("'", "'\\''") + "'"
}
init {
Shell.enableVerboseLogging = true
Shell.enableVerboseLogging = BuildConfig.DEBUG
Shell.setDefaultBuilder(
Shell.Builder.create()
.setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_REDIRECT_STDERR)
.setFlags(Shell.FLAG_MOUNT_MASTER)
.setTimeout(10)
)
}

View File

@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2026 Shiham Abdul Rahman <shihaam@shihaam.dev>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package sh.sar.isodroid.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
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.unit.dp
@Composable
fun UsbWarningDialog(
isUnmount: Boolean,
onDismiss: () -> Unit,
onConfirm: (dontShowAgain: Boolean) -> Unit
) {
var dontShowAgain by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "USB Services Will Restart",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column {
Text(
text = if (isUnmount) {
"Unmounting will restart USB services on your device, including:"
} else {
"Mounting will restart USB services on your device, including:"
},
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "• MTP file transfer\n• USB ADB\n• USB tethering\n• Other USB functions",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Active file transfers may be interrupted and could result in data corruption. Make sure no transfers are in progress.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = dontShowAgain,
onCheckedChange = { dontShowAgain = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Don't show this again",
style = MaterialTheme.typography.bodyMedium
)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(dontShowAgain) }
) {
Text(if (isUnmount) "Unmount" else "Mount")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -12,6 +12,8 @@ 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.WindowInsets
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -22,8 +24,8 @@ 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.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -68,9 +70,9 @@ private fun loadOsDownloads(context: Context): List<OsDownload> {
OsDownload(
name = obj.getString("name"),
category = obj.getString("category"),
subcategory = if (obj.isNull("subcategory")) null else obj.optString("subcategory", null),
subcategory = if (obj.isNull("subcategory")) null else obj.optString("subcategory"),
description = obj.optString("description", ""),
icon = if (obj.isNull("icon")) null else obj.optString("icon", null),
icon = if (obj.isNull("icon")) null else obj.optString("icon"),
url = obj.getString("url")
)
)
@@ -101,6 +103,7 @@ fun DownloadsScreen(
}
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
TopAppBar(
title = { Text("Download ISOs") },
@@ -124,6 +127,7 @@ fun DownloadsScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
) {
groupedDownloads.forEach { (category, osList) ->
@@ -280,7 +284,7 @@ private fun DownloadItem(
}
}
Icon(
imageVector = Icons.Default.OpenInNew,
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Open in browser",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)

View File

@@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -49,14 +51,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.CreateImgDialog
import sh.sar.isodroid.ui.components.FileContextMenu
import sh.sar.isodroid.ui.components.FileItemCard
import sh.sar.isodroid.ui.components.MountDialog
import sh.sar.isodroid.ui.components.StatusCard
import sh.sar.isodroid.ui.components.UsbWarningDialog
import sh.sar.isodroid.viewmodel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -69,12 +74,38 @@ fun MainScreen(
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
var selectedFile by remember { mutableStateOf<IsoFile?>(null) }
var showMountDialog by remember { mutableStateOf(false) }
var showCreateImgDialog by remember { mutableStateOf(false) }
var contextMenuFile by remember { mutableStateOf<IsoFile?>(null) }
// USB warning dialog state
var showUsbWarning by remember { mutableStateOf(false) }
var pendingMountPath by remember { mutableStateOf<String?>(null) }
var pendingMountOptions by remember { mutableStateOf<MountOptions?>(null) }
var isUnmountWarning by remember { mutableStateOf(false) }
val prefs = remember { context.getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) }
val skipUsbWarning = remember { mutableStateOf(prefs.getBoolean("skip_usb_warning", false)) }
fun showUsbWarningOrProceed(
isUnmount: Boolean,
mountPath: String? = null,
mountOptions: MountOptions? = null,
onProceed: () -> Unit
) {
if (skipUsbWarning.value) {
onProceed()
} else {
isUnmountWarning = isUnmount
pendingMountPath = mountPath
pendingMountOptions = mountOptions
showUsbWarning = true
}
}
val pullToRefreshState = rememberPullToRefreshState()
// Handle pull-to-refresh
@@ -112,6 +143,7 @@ fun MainScreen(
}
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
TopAppBar(
title = { Text("ISO Droid") },
@@ -150,8 +182,10 @@ fun MainScreen(
if (uiState.mountStatus.mounted) {
ExtendedFloatingActionButton(
onClick = {
scope.launch {
viewModel.unmount()
showUsbWarningOrProceed(isUnmount = true) {
scope.launch {
viewModel.unmount()
}
}
},
icon = {
@@ -173,6 +207,7 @@ fun MainScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.navigationBarsPadding()
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
if (uiState.isLoading && !pullToRefreshState.isRefreshing) {
@@ -295,8 +330,14 @@ fun MainScreen(
val filePath = file.path // Capture path before clearing state
showMountDialog = false
selectedFile = null
scope.launch {
viewModel.mount(filePath, options)
showUsbWarningOrProceed(
isUnmount = false,
mountPath = filePath,
mountOptions = options
) {
scope.launch {
viewModel.mount(filePath, options)
}
}
}
)
@@ -337,4 +378,39 @@ fun MainScreen(
}
)
}
// USB warning dialog
if (showUsbWarning) {
UsbWarningDialog(
isUnmount = isUnmountWarning,
onDismiss = {
showUsbWarning = false
pendingMountPath = null
pendingMountOptions = null
},
onConfirm = { dontShowAgain ->
if (dontShowAgain) {
prefs.edit().putBoolean("skip_usb_warning", true).apply()
skipUsbWarning.value = true
}
// Capture values before clearing state
val isUnmount = isUnmountWarning
val path = pendingMountPath
val options = pendingMountOptions
showUsbWarning = false
pendingMountPath = null
pendingMountOptions = null
scope.launch {
if (isUnmount) {
viewModel.unmount()
} else if (path != null && options != null) {
viewModel.mount(path, options)
}
}
}
)
}
}

View File

@@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -47,6 +49,7 @@ import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -58,6 +61,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -115,6 +119,10 @@ fun SettingsScreen(
)
}
// USB warning preference
val prefs = remember { context.getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) }
var showUsbWarning by remember { mutableStateOf(!prefs.getBoolean("skip_usb_warning", false)) }
// Re-check permissions when returning to the app
if (activity != null) {
androidx.compose.runtime.DisposableEffect(activity) {
@@ -145,6 +153,7 @@ fun SettingsScreen(
}
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
TopAppBar(
title = { Text("Settings") },
@@ -168,6 +177,7 @@ fun SettingsScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
) {
// Storage section
@@ -246,6 +256,61 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Warnings section
SectionHeader(title = "Warnings")
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "USB restart warning",
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Show warning before mount/unmount about USB service interruption",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Switch(
checked = showUsbWarning,
onCheckedChange = { enabled ->
showUsbWarning = enabled
prefs.edit().putBoolean("skip_usb_warning", !enabled).apply()
}
)
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// About section
SectionHeader(title = "About")
@@ -456,9 +521,10 @@ private fun DirectoryBrowserDialog(
fun loadContents(path: String) {
scope.launch {
isLoading = true
val safePath = RootManager.shellEscape(path)
// Load directories
val dirResult = RootManager.executeCommand(
"find \"$path\" -maxdepth 1 -mindepth 1 -type d 2>/dev/null"
"find $safePath -maxdepth 1 -mindepth 1 -type d 2>/dev/null"
)
val directories = if (dirResult.success && dirResult.output.isNotBlank()) {
dirResult.output.lines()
@@ -467,8 +533,9 @@ private fun DirectoryBrowserDialog(
.filter { !it.substringAfterLast("/").startsWith(".") }
.map { dirPath ->
// Check if this directory was created by the app (has .isodroiddir marker)
val safeDirPath = RootManager.shellEscape(dirPath)
val markerCheck = RootManager.executeCommand(
"test -f \"$dirPath/.isodroiddir\" && echo 'yes' || echo 'no'"
"test -f $safeDirPath/.isodroiddir && echo 'yes' || echo 'no'"
)
val isDeletable = markerCheck.output.trim() == "yes"
BrowserItem(dirPath.substringAfterLast("/"), true, dirPath, isDeletable)
@@ -479,7 +546,7 @@ private fun DirectoryBrowserDialog(
// Load ISO/IMG files
val fileResult = RootManager.executeCommand(
"find \"$path\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
"find $safePath -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
)
val files = if (fileResult.success && fileResult.output.isNotBlank()) {
fileResult.output.lines()
@@ -501,9 +568,10 @@ private fun DirectoryBrowserDialog(
if (trimmedName.isEmpty()) return@launch
val newPath = "$currentPath/$trimmedName"
RootManager.executeCommand("mkdir -p \"$newPath\"")
val safeNewPath = RootManager.shellEscape(newPath)
RootManager.executeCommand("mkdir -p $safeNewPath")
// Create marker file to indicate this folder was created by the app
RootManager.executeCommand("touch \"$newPath/.isodroiddir\"")
RootManager.executeCommand("touch $safeNewPath/.isodroiddir")
// Auto-navigate into the new folder
currentPath = newPath
}
@@ -511,7 +579,8 @@ private fun DirectoryBrowserDialog(
fun deleteFolder(path: String) {
scope.launch {
RootManager.executeCommand("rm -rf \"$path\"")
val safePath = RootManager.shellEscape(path)
RootManager.executeCommand("rm -rf $safePath")
loadContents(currentPath)
}
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -69,15 +70,18 @@ fun SetupWizardScreen(
0 -> WelcomeStep(
onNext = { currentStep = 1 }
)
1 -> RootAccessStep(
onNext = { currentStep = 2 },
onSkip = { currentStep = 2 }
1 -> DisclaimerStep(
onNext = { currentStep = 2 }
)
2 -> NotificationStep(
2 -> RootAccessStep(
onNext = { currentStep = 3 },
onSkip = { currentStep = 3 }
)
3 -> CompleteStep(
3 -> NotificationStep(
onNext = { currentStep = 4 },
onSkip = { currentStep = 4 }
)
4 -> CompleteStep(
onFinish = onSetupComplete
)
}
@@ -131,6 +135,53 @@ private fun WelcomeStep(
}
}
@Composable
private fun DisclaimerStep(
onNext: () -> Unit
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Before You Continue",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "This app requires root access and performs system-level operations. While reasonable precautions have been taken, you are responsible for understanding what you're doing.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Keep backups of important data. This software is provided \"as is\" without warranty.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue")
}
}
@Composable
private fun RootAccessStep(
onNext: () -> Unit,

View File

@@ -5,7 +5,6 @@
package sh.sar.isodroid.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -14,11 +13,7 @@ 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,
@@ -47,15 +42,6 @@ fun ISODroidTheme(
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = android.graphics.Color.TRANSPARENT
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,

View File

@@ -205,9 +205,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Create directory if it doesn't exist
if (!directory.exists()) {
RootManager.executeCommand("mkdir -p \"$currentPath\"")
val safePath = RootManager.shellEscape(currentPath)
RootManager.executeCommand("mkdir -p $safePath")
// Create marker file to indicate this folder was created by the app
RootManager.executeCommand("touch \"$currentPath/.isodroiddir\"")
RootManager.executeCommand("touch $safePath/.isodroiddir")
}
// Try multiple methods to list files
@@ -220,8 +221,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadFilesViaFind(currentPath: String): List<IsoFile>? {
// Use find command - more reliable for getting full paths
val safePath = RootManager.shellEscape(currentPath)
val result = RootManager.executeCommand(
"find \"$currentPath\" -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
"find $safePath -maxdepth 1 -type f \\( -iname '*.iso' -o -iname '*.img' \\) 2>/dev/null"
)
if (!result.success || result.output.isBlank()) return null
@@ -231,8 +233,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
.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")
// Get file size via stat with safe escaping
val safeFilePath = RootManager.shellEscape(filePath.trim())
val sizeResult = RootManager.executeCommand("stat -c %s $safeFilePath 2>/dev/null")
val size = sizeResult.output.trim().toLongOrNull() ?: 0L
IsoFile(
path = filePath.trim(),
@@ -246,8 +249,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private suspend fun loadFilesViaLs(currentPath: String): List<IsoFile>? {
// Simple ls command - just get filenames
val safePath = RootManager.shellEscape(currentPath)
val result = RootManager.executeCommand(
"ls \"$currentPath\" 2>/dev/null"
"ls $safePath 2>/dev/null"
)
if (!result.success || result.output.isBlank()) return null
@@ -259,8 +263,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
.mapNotNull { name ->
val filePath = "$currentPath/$name"
// Get file size via stat
val sizeResult = RootManager.executeCommand("stat -c %s \"$filePath\" 2>/dev/null")
// Get file size via stat with safe escaping
val safeFilePath = RootManager.shellEscape(filePath)
val sizeResult = RootManager.executeCommand("stat -c %s $safeFilePath 2>/dev/null")
val size = sizeResult.output.trim().toLongOrNull() ?: 0L
IsoFile(
path = filePath,
@@ -444,13 +449,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val blockSize = 1024 * 1024L // 1MB blocks
val totalBlocks = totalBytes / blockSize
var writtenBlocks = 0L
val safeFilePath = RootManager.shellEscape(filePath)
// Create file with dd in background, checking for cancellation
val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
// First, create the file with truncate to reserve space indication
val createResult = RootManager.executeCommand(
"dd if=/dev/zero of=\"$filePath\" bs=1M count=0 seek=$totalBlocks 2>/dev/null"
"dd if=/dev/zero of=$safeFilePath bs=1M count=0 seek=$totalBlocks 2>/dev/null"
)
if (!createResult.success) {
@@ -461,18 +467,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
while (writtenBlocks < totalBlocks) {
if (CreateImgEventBus.isCancelRequested()) {
// Clean up partial file
RootManager.executeCommand("rm -f \"$filePath\"")
RootManager.executeCommand("rm -f $safeFilePath")
return@withContext false
}
// Write a chunk (up to 64MB at a time for efficiency)
val chunksToWrite = minOf(64, totalBlocks - writtenBlocks)
val chunkResult = RootManager.executeCommand(
"dd if=/dev/zero of=\"$filePath\" bs=1M count=$chunksToWrite seek=$writtenBlocks conv=notrunc 2>/dev/null"
"dd if=/dev/zero of=$safeFilePath bs=1M count=$chunksToWrite seek=$writtenBlocks conv=notrunc 2>/dev/null"
)
if (!chunkResult.success) {
RootManager.executeCommand("rm -f \"$filePath\"")
RootManager.executeCommand("rm -f $safeFilePath")
return@withContext false
}
@@ -484,7 +490,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
true
} catch (e: Exception) {
RootManager.executeCommand("rm -f \"$filePath\"")
RootManager.executeCommand("rm -f $safeFilePath")
false
}
}
@@ -508,14 +514,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val oldPath = file.path
val newPath = "${oldPath.substringBeforeLast("/")}/$newName"
// Use shell-safe escaping to prevent command injection
val safeOldPath = RootManager.shellEscape(oldPath)
val safeNewPath = RootManager.shellEscape(newPath)
// Check if new file already exists
val checkResult = RootManager.executeCommand("test -f \"$newPath\" && echo exists")
val checkResult = RootManager.executeCommand("test -f $safeNewPath && echo exists")
if (checkResult.output.trim() == "exists") {
_uiState.update { it.copy(errorMessage = "File already exists: $newName") }
return@launch
}
val result = RootManager.executeCommand("mv \"$oldPath\" \"$newPath\"")
val result = RootManager.executeCommand("mv $safeOldPath $safeNewPath")
if (result.success) {
_uiState.update { it.copy(successMessage = "Renamed to $newName") }
loadFiles()
@@ -527,7 +537,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun deleteFile(file: IsoFile) {
viewModelScope.launch {
val result = RootManager.executeCommand("rm -f \"${file.path}\"")
val safePath = RootManager.shellEscape(file.path)
val result = RootManager.executeCommand("rm -f $safePath")
if (result.success) {
_uiState.update { it.copy(successMessage = "Deleted ${file.name}") }
loadFiles()

View File

@@ -1,77 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Build isodrive from source for all Android architectures
# Requires: Android NDK (or runs via nix-shell on NixOS)
SCRIPT_DIR=$(dirname "$(realpath "$0")")
ISODRIVE_DIR="/tmp/isodrive"
OUTPUT_DIR="$SCRIPT_DIR/app/src/main/assets/bin"
# Clone isodrive source
if [[ -d "$ISODRIVE_DIR" ]]; then
echo "Updating isodrive source..."
git -C "$ISODRIVE_DIR" pull
else
echo "Cloning isodrive..."
git clone --depth 1 https://github.com/nitanmarcel/isodrive "$ISODRIVE_DIR"
fi
SRCS="$ISODRIVE_DIR/src/util.cpp $ISODRIVE_DIR/src/configfsisomanager.cpp $ISODRIVE_DIR/src/androidusbisomanager.cpp $ISODRIVE_DIR/src/main.cpp"
CFLAGS="-I$ISODRIVE_DIR/src/include -static-libstdc++ -Os -s"
build_all() {
local NDK="$1"
local TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
echo "Building arm64-v8a..."
"$TOOLCHAIN/aarch64-linux-android26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/arm64-v8a/isodrive"
echo "Building armeabi-v7a..."
"$TOOLCHAIN/armv7a-linux-androideabi26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/armeabi-v7a/isodrive"
echo "Building x86_64..."
"$TOOLCHAIN/x86_64-linux-android26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/x86_64/isodrive"
echo "Building x86..."
"$TOOLCHAIN/i686-linux-android26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/x86/isodrive"
echo "Done! Built isodrive for all architectures."
ls -la "$OUTPUT_DIR"/*/isodrive
}
# On NixOS, prefer nix-shell (local Android SDK has /bin/bash issues)
if command -v nix-shell &>/dev/null; then
echo "Using nix-shell to get Android NDK..."
export SRCS CFLAGS OUTPUT_DIR
NIXPKGS_ALLOW_UNFREE=1 nix-shell -p androidenv.androidPkgs.ndk-bundle --run '
SDK_ROOT=$(find /nix/store -maxdepth 1 -name "*android-sdk-ndk*" -type d 2>/dev/null | head -1)
NDK=$(ls -d "$SDK_ROOT/libexec/android-sdk/ndk/"* | head -1)
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
echo "Using NDK: $NDK"
echo "Building arm64-v8a..."
"$TOOLCHAIN/aarch64-linux-android26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/arm64-v8a/isodrive"
echo "Building armeabi-v7a..."
"$TOOLCHAIN/armv7a-linux-androideabi26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/armeabi-v7a/isodrive"
echo "Building x86_64..."
"$TOOLCHAIN/x86_64-linux-android26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/x86_64/isodrive"
echo "Building x86..."
"$TOOLCHAIN/i686-linux-android26-clang++" $CFLAGS $SRCS -o "$OUTPUT_DIR/x86/isodrive"
echo "Done!"
ls -la "$OUTPUT_DIR"/*/isodrive
'
elif [[ -n "${ANDROID_NDK_HOME:-}" ]]; then
build_all "$ANDROID_NDK_HOME"
elif [[ -n "${ANDROID_NDK:-}" ]]; then
build_all "$ANDROID_NDK"
else
echo "Error: Android NDK not found."
echo "Set ANDROID_NDK_HOME or ANDROID_NDK, or install nix-shell."
exit 1
fi

View File

@@ -1,53 +1,50 @@
# Building ISO Droid
This document explains how to build ISO Droid from source and contribute to the project.
This document explains how to build ISO Droid from source.
## Requirements
- **Android Studio** (or command-line Gradle)
- **Android SDK** (API 26+)
- **Android NDK** (for compiling isodrive binary)
- **Git** (with submodule support)
## Building from Source
### Prerequisites
### 1. Clone with Submodules
**1. isodrive Binary (Required)**
ISO Droid requires the `isodrive` binary to function. This binary is the actual tool that communicates with the Linux kernel's USB gadget subsystem to mount ISO/IMG files.
- **Source**: The binary is compiled from [nitanmarcel/isodrive](https://github.com/nitanmarcel/isodrive)
- **Why required**: Android apps cannot directly access USB gadget interfaces - they need a native binary with root privileges
- **Location**: Place the binary at `app/src/main/assets/bin/isodrive`
- **Supported architectures**: ARM64 (arm64-v8a), ARM32 (armeabi-v7a), x86, x86_64
**How to obtain the binary:**
1. Download the latest release from [isodrive-magisk releases](https://github.com/nitanmarcel/isodrive-magisk/releases/latest)
2. Extract the `isodrive-magisk-v<version>.zip` file
3. The binaries for each architecture are in the `libs/` directory:
```
libs/
├── arm64-v8a/
│ └── isodrive
├── armeabi-v7a/
│ └── isodrive
├── x86/
│ └── isodrive
└── x86_64/
└── isodrive
```
4. Copy the binary for your target architecture to `app/src/main/assets/bin/isodrive`
- For most modern devices: use `arm64-v8a/isodrive`
- The app can bundle multiple architectures if needed
### Build Steps
ISO Droid uses [isodrive](https://git.shihaam.dev/shihaam/isodrive) as a git submodule. Clone with submodules:
```bash
# Clone the repository
git clone https://github.com/sargit/ISODroid.git
git clone --recurse-submodules https://git.shihaam.dev/shihaam/ISODroid.git
cd ISODroid
```
# Ensure isodrive binary is present
ls -la app/src/main/assets/bin/isodrive
Or if already cloned, initialize submodules:
```bash
git submodule update --init
```
### 2. Install Android NDK
The isodrive binary is compiled from source during the build. This requires the Android NDK.
**Android Studio:**
1. Go to **Tools → SDK Manager**
2. Click **SDK Tools** tab
3. Check **NDK (Side by side)**
4. Click **Apply**
**NixOS:**
The build automatically uses `nix-shell` to get the NDK if available.
**Manual:**
Set `ANDROID_NDK_HOME` environment variable to your NDK path.
### 3. Build
```bash
# Build debug APK
./gradlew assembleDebug
@@ -55,105 +52,38 @@ ls -la app/src/main/assets/bin/isodrive
./gradlew assembleRelease
```
The compiled APK will be in:
- Debug: `app/debug/app-debug.apk`
- Release: `app/release/app-release.apk`
The Gradle build will automatically:
- Compile isodrive for all architectures (arm64-v8a, armeabi-v7a, x86_64, x86)
- Bundle the binaries into the APK assets
- Skip compilation if binaries already exist
## Contributing
**Output locations:**
- Debug: `app/build/outputs/apk/debug/app-debug.apk`
- Release: `app/build/outputs/apk/release/app-release.apk`
### Adding OS Icons
### Build from Android Studio
OS icons are displayed in the file browser when ISO/IMG filenames match the icon name.
1. Open the project in Android Studio
2. Ensure NDK is installed (see above)
3. Click **Build → Build Bundle(s) / APK(s) → Build APK(s)**
**Steps:**
The isodrive binary will be compiled automatically before the APK is built.
1. Add your SVG file to `app/src/main/assets/osicons/`
- Filename should be lowercase (e.g., `ubuntu.svg`, `archlinux.svg`)
- SVG should be simple and recognizable
- Recommended: Get icons from [Simple Icons](https://simpleicons.org/)
### Manual isodrive Build (Optional)
2. The app automatically detects and matches icons:
- Filename `ubuntu-22.04-desktop-amd64.iso` → matches `ubuntu.svg`
- Filename `archlinux-2024.12.01-x86_64.iso` → matches `archlinux.svg`
- Matching is case-insensitive and searches for icon name within filename
To manually trigger isodrive compilation:
3. **Symlinks for filename variations:**
Sometimes ISO files use shortened or alternate names. Create symlinks to match these variations:
```bash
cd app/src/main/assets/osicons/
# Linux Mint ISOs often named "mint-*.iso"
ln -s linuxmint.svg mint.svg
# Windows ISOs might be "Win11.iso" or "Win.iso"
ln -s windows.svg win.svg
ln -s windows.svg win11.svg
# FreeBSD might be shortened
ln -s freebsd.svg bsd.svg
```
This ensures `mint-21.3-cinnamon.iso` matches even though the icon is `linuxmint.svg`
4. Submit a pull request with your new icon(s) and any necessary symlinks
### Adding OS Download Links
The Downloads screen shows curated links to operating system ISOs.
**Steps:**
1. Edit `app/src/main/assets/os.json`
2. Add a new entry with all required fields:
```json
{
"name": "Ubuntu Desktop",
"category": "Linux",
"description": "Popular Linux distribution with GNOME desktop",
"icon": "ubuntu.svg",
"url": "https://ubuntu.com/download/desktop"
}
```bash
./gradlew buildIsodrive
```
**Field descriptions:**
To force rebuild, delete existing binaries first:
- `name` (required): Display name for the OS
- `category` (required): One of: `Linux`, `BSD`, `Windows`, or `Recovery`
- `description` (required): Brief description (one sentence)
- `icon` (required): Filename of SVG in `osicons/` directory, or `null` if no icon
- `url` (required): Direct link to download page or ISO file
**Example with no icon:**
```json
{
"name": "Custom Linux",
"category": "Linux",
"description": "A custom Linux distribution",
"icon": null,
"url": "https://example.com/download"
}
```bash
rm -rf app/src/main/assets/bin/*/isodrive
./gradlew buildIsodrive
```
3. Test your changes:
- Build the app
- Navigate to Downloads screen
- Verify your entry appears in the correct category
- Verify the icon displays (if provided)
- Verify the link opens correctly
## F-Droid Build
4. Submit a pull request with:
- Updated `os.json`
- New SVG icon in `osicons/` (if applicable)
- Brief description of what you added
## Testing
Before submitting a PR, test on a real rooted Android device:
- [ ] App builds successfully
- [ ] New OS icon displays correctly
- [ ] New download link opens properly
- [ ] No crashes or errors in logcat
F-Droid uses `srclibs` instead of git submodules. The F-Droid metadata pre-builds isodrive before running Gradle, so the Gradle task skips compilation (binaries already exist).

99
docs/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,99 @@
# Contributing to ISO Droid
This document explains how to contribute OS icons and download links to ISO Droid.
For building the app, see [BUILDING.md](BUILDING.md).
## Adding OS Icons
OS icons are displayed in the file browser when ISO/IMG filenames match the icon name.
**Steps:**
1. Add your SVG file to `app/src/main/assets/osicons/`
- Filename should be lowercase (e.g., `ubuntu.svg`, `archlinux.svg`)
- SVG should be simple and recognizable
- Recommended: Get icons from [Simple Icons](https://simpleicons.org/)
2. The app automatically detects and matches icons:
- Filename `ubuntu-22.04-desktop-amd64.iso` → matches `ubuntu.svg`
- Filename `archlinux-2024.12.01-x86_64.iso` → matches `archlinux.svg`
- Matching is case-insensitive and searches for icon name within filename
3. **Symlinks for filename variations:**
Sometimes ISO files use shortened or alternate names. Create symlinks to match these variations:
```bash
cd app/src/main/assets/osicons/
# Linux Mint ISOs often named "mint-*.iso"
ln -s linuxmint.svg mint.svg
# Windows ISOs might be "Win11.iso" or "Win.iso"
ln -s windows.svg win.svg
ln -s windows.svg win11.svg
```
This ensures `mint-21.3-cinnamon.iso` matches even though the icon is `linuxmint.svg`
4. Submit a pull request with your new icon(s) and any necessary symlinks
## Adding OS Download Links
The Downloads screen shows curated links to operating system ISOs.
**Steps:**
1. Edit `app/src/main/assets/os.json`
2. Add a new entry with all required fields:
```json
{
"name": "Ubuntu Desktop",
"category": "Linux",
"description": "Popular Linux distribution with GNOME desktop",
"icon": "ubuntu.svg",
"url": "https://ubuntu.com/download/desktop"
}
```
**Field descriptions:**
- `name` (required): Display name for the OS
- `category` (required): One of: `Linux`, `BSD`, `Windows`, or `Recovery`
- `description` (required): Brief description (one sentence)
- `icon` (required): Filename of SVG in `osicons/` directory, or `null` if no icon
- `url` (required): Direct link to download page or ISO file
**Example with no icon:**
```json
{
"name": "Custom Linux",
"category": "Linux",
"description": "A custom Linux distribution",
"icon": null,
"url": "https://example.com/download"
}
```
3. Test your changes:
- Build the app (see [BUILDING.md](BUILDING.md))
- Navigate to Downloads screen
- Verify your entry appears in the correct category
- Verify the icon displays (if provided)
- Verify the link opens correctly
4. Submit a pull request with:
- Updated `os.json`
- New SVG icon in `osicons/` (if applicable)
- Brief description of what you added
## Testing
Before submitting a PR, test on a real rooted Android device:
- [ ] App builds successfully
- [ ] New OS icon displays correctly
- [ ] New download link opens properly
- [ ] No crashes or errors in logcat

38
docs/DISCLAIMER.md Normal file
View File

@@ -0,0 +1,38 @@
# Disclaimer
## About This Project
ISO Droid was developed with AI assistance (Claude by Anthropic). This is mentioned for transparency, regardless of who or what wrote it, it can contain bugs.
Reasonable precautions have been taken to prevent unintended behavior.
## Root Access
This app requires root access to function. Root access gives applications elevated privileges on your device, which means:
- Operations can affect system-level functionality
- Mistakes can potentially cause issues that require recovery
- You should understand what an operation does before executing it
## Recommendations
- **Keep backups** of important data (good practice regardless)
- **Understand the operations** before running them
- **Test on non-critical setups first** if you're unsure
- **Report bugs** if you encounter them
## My Usage
I (the developer) use this app on my own devices regularly. It works for my use cases, but your device, kernel, and setup may differ, and there may be edge cases I haven't encountered.
## No Warranty
This software is provided "as is" without warranty of any kind. See the [LICENSE](../LICENSE) file for full details (GPL-3.0).
## Bug Reports & Contributions
Found a bug? Have a suggestion? Please open an issue:
- **Issues**: [git.sargit.com/sargit/ISODroid/issues](https://git.sargit.com/sargit/ISODroid/issues)
Contributions are welcome. If you fix something or improve the app, consider submitting a pull request.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -0,0 +1,5 @@
• Added disclaimer screen in welcome wizard about user responsibility
• Added USB services restart warning before mount/unmount (with "Don't show again" option)
• Added toggle in Settings to enable/disable USB restart warning
• Fixed shell escape exploits in shell commands
• Disabled logcat on release builds

View File

@@ -0,0 +1,2 @@
- Always use app-bundled isodrive binary instead of system binary
- Prep for edge-to-edge compatibility

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 145 KiB

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Get the directory where this script lives
SCRIPT_DIR=$(dirname "$(realpath "$0")")
ISODRIVE_VERSION=$(curl -sI https://github.com/nitanmarcel/isodrive-magisk/releases/latest | grep -i ^location | grep -oP 'v\K[\d.]+')
curl -sL https://github.com/nitanmarcel/isodrive-magisk/releases/download/v$ISODRIVE_VERSION/isodrive-magisk-v$ISODRIVE_VERSION.zip -o /tmp/isodrive-magisk.zip
unzip -q /tmp/isodrive-magisk.zip -d /tmp/isodrive-magisk
# Move the isodrive binary for each architecture
mv /tmp/isodrive-magisk/libs/arm64-v8a/isodrive $SCRIPT_DIR/app/src/main/assets/bin/arm64-v8a/
mv /tmp/isodrive-magisk/libs/armeabi-v7a/isodrive $SCRIPT_DIR/app/src/main/assets/bin/armeabi-v7a/
mv /tmp/isodrive-magisk/libs/x86/isodrive $SCRIPT_DIR/app/src/main/assets/bin/x86/
mv /tmp/isodrive-magisk/libs/x86_64/isodrive $SCRIPT_DIR/app/src/main/assets/bin/x86_64/
# Clean up temp files
rm -rf /tmp/isodrive-magisk /tmp/isodrive-magisk.zip

1
isodrive Submodule

Submodule isodrive added at 2e9f28bd90