From 86bf14f9d9613522e1b68fdfc6f8784231f769d2 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 10 Mar 2026 03:20:41 +0500 Subject: [PATCH] add setup wizard, remove stoage api permission since root is used for that --- app/src/main/AndroidManifest.xml | 10 - .../main/java/sh/sar/isodroid/MainActivity.kt | 103 ++---- .../isodroid/ui/screens/SetupWizardScreen.kt | 342 ++++++++++++++++++ .../sar/isodroid/viewmodel/MainViewModel.kt | 20 +- 4 files changed, 383 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/sh/sar/isodroid/ui/screens/SetupWizardScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ede6351..7559476 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,16 +2,6 @@ - - - - - - diff --git a/app/src/main/java/sh/sar/isodroid/MainActivity.kt b/app/src/main/java/sh/sar/isodroid/MainActivity.kt index 937a007..fae1f5e 100644 --- a/app/src/main/java/sh/sar/isodroid/MainActivity.kt +++ b/app/src/main/java/sh/sar/isodroid/MainActivity.kt @@ -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() } } diff --git a/app/src/main/java/sh/sar/isodroid/ui/screens/SetupWizardScreen.kt b/app/src/main/java/sh/sar/isodroid/ui/screens/SetupWizardScreen.kt new file mode 100644 index 0000000..215c25a --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/SetupWizardScreen.kt @@ -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(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 + ) + } + } +} diff --git a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt index b5be1b0..0f30d8e 100644 --- a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt +++ b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt @@ -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) {