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

@@ -1,17 +1,10 @@
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.content.Context
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
@@ -20,7 +13,6 @@ 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
@@ -28,39 +20,14 @@ import androidx.navigation.compose.rememberNavController
import sh.sar.isodroid.ui.screens.DownloadsScreen
import sh.sar.isodroid.ui.screens.MainScreen
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.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 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()
}
}
private var setupComplete by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -68,7 +35,13 @@ class MainActivity : ComponentActivity() {
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 {
ISODroidTheme {
@@ -76,50 +49,30 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ISODroidNavHost(viewModel = viewModel)
if (setupComplete) {
ISODroidNavHost(viewModel = viewModel)
} else {
SetupWizardScreen(
onSetupComplete = {
markSetupComplete()
setupComplete = true
viewModel.initialize()
}
)
}
}
}
}
}
private fun checkAndRequestPermissions() {
// Request notification permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun isSetupComplete(): Boolean {
val prefs = getSharedPreferences("iso_drive_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean("setup_complete", false)
}
// Request storage permission
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
}
}
private fun markSetupComplete() {
val prefs = getSharedPreferences("iso_drive_prefs", Context.MODE_PRIVATE)
prefs.edit().putBoolean("setup_complete", true).apply()
}
}

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"
}
init {
viewModelScope.launch {
initialize()
}
private var initialized = false
init {
// Observe mount events from notification actions
viewModelScope.launch {
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) }
// Load saved ISO directory
@@ -77,8 +83,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) }
navigationStack.add(savedDirectory)
// Check root access
val hasRoot = RootManager.requestRoot()
// Check root access (don't request, just check - wizard handles requesting)
val hasRoot = RootManager.hasRoot()
_uiState.update { it.copy(hasRoot = hasRoot) }
if (hasRoot) {