add setup wizard, remove stoage api permission since root is used for that

This commit is contained in:
2026-03-10 03:20:41 +05:00
parent e87510dc40
commit 86bf14f9d9
4 changed files with 383 additions and 92 deletions

View File

@@ -2,16 +2,6 @@
<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" />
<!-- Notification permission for Android 13+ --> <!-- Notification permission for Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -1,17 +1,10 @@
package sh.sar.isodroid package sh.sar.isodroid
import android.Manifest import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -20,7 +13,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -28,39 +20,14 @@ import androidx.navigation.compose.rememberNavController
import sh.sar.isodroid.ui.screens.DownloadsScreen import sh.sar.isodroid.ui.screens.DownloadsScreen
import sh.sar.isodroid.ui.screens.MainScreen import sh.sar.isodroid.ui.screens.MainScreen
import sh.sar.isodroid.ui.screens.SettingsScreen import sh.sar.isodroid.ui.screens.SettingsScreen
import sh.sar.isodroid.ui.screens.SetupWizardScreen
import sh.sar.isodroid.ui.theme.ISODroidTheme import sh.sar.isodroid.ui.theme.ISODroidTheme
import sh.sar.isodroid.viewmodel.MainViewModel import sh.sar.isodroid.viewmodel.MainViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var viewModel: MainViewModel private lateinit var viewModel: MainViewModel
private var hasStoragePermission by mutableStateOf(false) private var setupComplete by mutableStateOf(false)
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasStoragePermission = permissions.values.all { it }
if (hasStoragePermission) {
viewModel.refresh()
}
}
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* Notification permission result - we continue regardless */ }
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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -68,7 +35,13 @@ class MainActivity : ComponentActivity() {
viewModel = ViewModelProvider(this)[MainViewModel::class.java] viewModel = ViewModelProvider(this)[MainViewModel::class.java]
checkAndRequestPermissions() // Check if setup was completed previously
setupComplete = isSetupComplete()
// Initialize ViewModel if setup is already complete
if (setupComplete) {
viewModel.initialize()
}
setContent { setContent {
ISODroidTheme { ISODroidTheme {
@@ -76,50 +49,30 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
ISODroidNavHost(viewModel = viewModel) if (setupComplete) {
ISODroidNavHost(viewModel = viewModel)
} else {
SetupWizardScreen(
onSetupComplete = {
markSetupComplete()
setupComplete = true
viewModel.initialize()
}
)
}
} }
} }
} }
} }
private fun checkAndRequestPermissions() { private fun isSetupComplete(): Boolean {
// Request notification permission on Android 13+ val prefs = getSharedPreferences("iso_drive_prefs", Context.MODE_PRIVATE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return prefs.getBoolean("setup_complete", false)
if (ContextCompat.checkSelfPermission( }
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
// Request storage permission private fun markSetupComplete() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val prefs = getSharedPreferences("iso_drive_prefs", Context.MODE_PRIVATE)
if (!Environment.isExternalStorageManager()) { prefs.edit().putBoolean("setup_complete", true).apply()
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
}
}
} }
} }

View File

@@ -0,0 +1,342 @@
package sh.sar.isodroid.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.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.material.icons.Icons
import androidx.compose.material.icons.filled.Album
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import sh.sar.isodroid.root.RootManager
@Composable
fun SetupWizardScreen(
onSetupComplete: () -> Unit
) {
var currentStep by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (currentStep) {
0 -> WelcomeStep(
onNext = { currentStep = 1 }
)
1 -> RootAccessStep(
onNext = { currentStep = 2 },
onSkip = { currentStep = 2 }
)
2 -> NotificationStep(
onNext = { currentStep = 3 },
onSkip = { currentStep = 3 }
)
3 -> CompleteStep(
onFinish = onSetupComplete
)
}
}
}
@Composable
private fun WelcomeStep(
onNext: () -> Unit
) {
Icon(
imageVector = Icons.Default.Album,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Welcome to ISO Drive",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Mount ISO and IMG files as USB drives directly from your Android device.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Let's set up a few things to get started.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth()
) {
Text("Get Started")
}
}
@Composable
private fun RootAccessStep(
onNext: () -> Unit,
onSkip: () -> Unit
) {
val scope = rememberCoroutineScope()
var hasRoot by remember { mutableStateOf<Boolean?>(null) }
var isChecking by remember { mutableStateOf(false) }
PermissionCard(
icon = Icons.Default.Security,
title = "Root Access",
description = "ISO Drive needs superuser (root) access to mount ISO files as USB devices. This is required because mounting USB gadgets is a system-level operation.",
granted = hasRoot
)
Spacer(modifier = Modifier.height(32.dp))
if (hasRoot == true) {
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue")
}
} else {
Button(
onClick = {
isChecking = true
scope.launch {
hasRoot = RootManager.requestRoot()
isChecking = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = !isChecking
) {
Text(if (isChecking) "Requesting..." else "Grant Root Access")
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text("Skip for now")
}
}
}
@Composable
private fun NotificationStep(
onNext: () -> Unit,
onSkip: () -> Unit
) {
val context = LocalContext.current
// Check if we need to show this step at all (only Android 13+)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// Auto-advance on older Android versions
onNext()
return
}
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
hasPermission = granted
}
PermissionCard(
icon = Icons.Default.Notifications,
title = "Notifications",
description = "ISO Drive shows a notification when an ISO is mounted, with a quick unmount button. This helps you keep track of the mount status.",
granted = hasPermission
)
Spacer(modifier = Modifier.height(32.dp))
if (hasPermission) {
Button(
onClick = onNext,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue")
}
} else {
Button(
onClick = {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Enable Notifications")
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text("Skip for now")
}
}
}
@Composable
private fun CompleteStep(
onFinish: () -> Unit
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "You're all set!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "ISO Drive is ready to use. Place your ISO or IMG files in the isodrive folder and start mounting.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default directory: /sdcard/isodrive/",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(48.dp))
Button(
onClick = onFinish,
modifier = Modifier.fillMaxWidth()
) {
Text("Start Using ISO Drive")
}
}
@Composable
private fun PermissionCard(
icon: ImageVector,
title: String,
description: String,
granted: Boolean?
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
if (granted != null) {
Icon(
imageVector = if (granted) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = if (granted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -45,11 +45,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private val DEFAULT_ISO_DIRECTORY = "${Environment.getExternalStorageDirectory().absolutePath}/isodrive" private val DEFAULT_ISO_DIRECTORY = "${Environment.getExternalStorageDirectory().absolutePath}/isodrive"
} }
init { private var initialized = false
viewModelScope.launch {
initialize()
}
init {
// Observe mount events from notification actions // Observe mount events from notification actions
viewModelScope.launch { viewModelScope.launch {
MountEventBus.events.collect { event -> MountEventBus.events.collect { event ->
@@ -66,7 +64,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
private suspend fun initialize() { fun initialize() {
if (initialized) return
initialized = true
viewModelScope.launch {
doInitialize()
}
}
private suspend fun doInitialize() {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
// Load saved ISO directory // Load saved ISO directory
@@ -77,8 +83,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) } _uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) }
navigationStack.add(savedDirectory) navigationStack.add(savedDirectory)
// Check root access // Check root access (don't request, just check - wizard handles requesting)
val hasRoot = RootManager.requestRoot() val hasRoot = RootManager.hasRoot()
_uiState.update { it.copy(hasRoot = hasRoot) } _uiState.update { it.copy(hasRoot = hasRoot) }
if (hasRoot) { if (hasRoot) {