diff --git a/app/src/main/java/sh/sar/isodroid/root/RootManager.kt b/app/src/main/java/sh/sar/isodroid/root/RootManager.kt index 0be2918..260df64 100644 --- a/app/src/main/java/sh/sar/isodroid/root/RootManager.kt +++ b/app/src/main/java/sh/sar/isodroid/root/RootManager.kt @@ -15,12 +15,36 @@ object RootManager { ) } - suspend fun hasRoot(): Boolean = withContext(Dispatchers.IO) { - Shell.isAppGrantedRoot() == true + /** + * Check if root was previously granted (cached status, no popup). + * Returns true if granted, false if denied, null if unknown. + */ + fun isRootGrantedCached(): Boolean? { + return Shell.isAppGrantedRoot() } + /** + * Check if root is available. Will trigger Magisk popup if not previously decided. + */ + suspend fun hasRoot(): Boolean = withContext(Dispatchers.IO) { + try { + // getShell() initializes the shell and checks root status with Magisk + // If already granted, Magisk will auto-approve silently + Shell.getShell().isRoot + } catch (e: Exception) { + false + } + } + + /** + * Request root access. Will trigger Magisk popup. + */ suspend fun requestRoot(): Boolean = withContext(Dispatchers.IO) { - Shell.getShell().isRoot + try { + Shell.getShell().isRoot + } catch (e: Exception) { + false + } } suspend fun executeCommand(command: String): CommandResult = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt b/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt index 1053556..9aaaa66 100644 --- a/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/SettingsScreen.kt @@ -1,7 +1,11 @@ package sh.sar.isodroid.ui.screens +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build +import android.provider.Settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,14 +14,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Error 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.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -40,9 +49,13 @@ import androidx.compose.runtime.remember 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.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import sh.sar.isodroid.viewmodel.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -53,9 +66,46 @@ fun SettingsScreen( ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + val activity = context as? androidx.activity.ComponentActivity var showPathDialog by remember { mutableStateOf(false) } var tempPath by remember(uiState.currentPath) { mutableStateOf(uiState.isoDirectory) } + // Track notification permission with lifecycle-aware refresh + var hasNotificationPermission by remember { + mutableStateOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else true + ) + } + + // Re-check permissions when returning to the app + if (activity != null) { + androidx.compose.runtime.DisposableEffect(activity) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + // Re-check notification permission + hasNotificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else true + + // Re-check root status (in case granted via Magisk) + viewModel.refreshRootStatus() + } + } + activity.lifecycle.addObserver(observer) + onDispose { + activity.lifecycle.removeObserver(observer) + } + } + } + fun openUrl(url: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) context.startActivity(intent) @@ -98,6 +148,61 @@ fun SettingsScreen( HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + // Permissions section + SectionHeader(title = "Permissions") + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Root Access status + val hasRoot = uiState.hasRoot + val rootDenied = uiState.rootDenied + PermissionStatusItem( + icon = Icons.Default.Security, + title = "Root Access", + granted = hasRoot, + grantedText = "Granted", + deniedText = if (rootDenied) { + "Denied. Open your root manager (Magisk/KernelSU) to grant access." + } else { + "Not granted. Tap to request root access." + }, + onClick = if (!hasRoot && !rootDenied) { + { viewModel.requestRootAccess() } + } else null + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Notification permission status (Android 13+ only) + PermissionStatusItem( + icon = Icons.Default.Notifications, + title = "Notifications", + granted = hasNotificationPermission, + grantedText = "Enabled", + deniedText = "Disabled. Tap to open system settings and enable notifications.", + onClick = if (!hasNotificationPermission) { + { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + } else null + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + // About section SectionHeader(title = "About") @@ -312,7 +417,7 @@ private fun SectionHeader(title: String) { @Composable private fun SettingsItem( - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit @@ -343,3 +448,53 @@ private fun SettingsItem( } } } + +@Composable +private fun PermissionStatusItem( + icon: ImageVector, + title: String, + granted: Boolean, + grantedText: String, + deniedText: String, + onClick: (() -> Unit)? +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (onClick != null) Modifier.clickable(onClick = onClick) + else Modifier + ), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = if (granted) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (granted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (granted) grantedText else deniedText, + style = MaterialTheme.typography.bodySmall, + color = if (granted) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error + ) + } + } +} 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 index 215c25a..652a8d8 100644 --- a/app/src/main/java/sh/sar/isodroid/ui/screens/SetupWizardScreen.kt +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/SetupWizardScreen.kt @@ -130,14 +130,25 @@ private fun RootAccessStep( onNext: () -> Unit, onSkip: () -> Unit ) { + val context = LocalContext.current val scope = rememberCoroutineScope() var hasRoot by remember { mutableStateOf(null) } var isChecking by remember { mutableStateOf(false) } + fun saveRootGranted(granted: Boolean) { + context.getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) + .edit() + .putBoolean("root_granted", granted) + .apply() + } + 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.", + description = when (hasRoot) { + false -> "Root access was denied. The app requires root to mount ISO files. You can try again or skip and grant access later from your root manager." + else -> "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 ) @@ -150,12 +161,27 @@ private fun RootAccessStep( ) { Text("Continue") } + } else if (hasRoot == false) { + // Magisk won't show the dialog again after denial + Button( + onClick = { + saveRootGranted(false) + onSkip() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Continue Without Root") + } } else { Button( onClick = { isChecking = true scope.launch { - hasRoot = RootManager.requestRoot() + val granted = RootManager.requestRoot() + hasRoot = granted + if (granted) { + saveRootGranted(true) + } isChecking = false } }, @@ -168,7 +194,10 @@ private fun RootAccessStep( Spacer(modifier = Modifier.height(12.dp)) OutlinedButton( - onClick = onSkip, + onClick = { + saveRootGranted(false) + onSkip() + }, modifier = Modifier.fillMaxWidth() ) { Text("Skip for now") @@ -182,6 +211,7 @@ private fun NotificationStep( onSkip: () -> Unit ) { val context = LocalContext.current + val activity = context as? android.app.Activity // Check if we need to show this step at all (only Android 13+) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { @@ -198,18 +228,34 @@ private fun NotificationStep( ) == PackageManager.PERMISSION_GRANTED ) } + var wasRequested by remember { mutableStateOf(false) } + var canRequestAgain by remember { mutableStateOf(true) } + + val wasDenied = wasRequested && !hasPermission + val permanentlyDenied = wasDenied && !canRequestAgain val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { granted -> hasPermission = granted + wasRequested = true + // Check if Android will show the permission dialog again + if (!granted && activity != null) { + canRequestAgain = androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale( + activity, Manifest.permission.POST_NOTIFICATIONS + ) + } } 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 + description = when { + permanentlyDenied -> "Notification permission was denied. You can enable it later in system settings if you change your mind." + wasDenied -> "Notification permission was denied. You can try again or continue without notifications." + else -> "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 = if (wasDenied) false else if (hasPermission) true else null ) Spacer(modifier = Modifier.height(32.dp)) @@ -221,6 +267,14 @@ private fun NotificationStep( ) { Text("Continue") } + } else if (permanentlyDenied) { + // Android won't show the popup again + Button( + onClick = onSkip, + modifier = Modifier.fillMaxWidth() + ) { + Text("Continue Without Notifications") + } } else { Button( onClick = { @@ -228,7 +282,7 @@ private fun NotificationStep( }, modifier = Modifier.fillMaxWidth() ) { - Text("Enable Notifications") + Text(if (wasDenied) "Try Again" else "Enable Notifications") } Spacer(modifier = Modifier.height(12.dp)) @@ -237,7 +291,7 @@ private fun NotificationStep( onClick = onSkip, modifier = Modifier.fillMaxWidth() ) { - Text("Skip for now") + Text(if (wasDenied) "Continue Without Notifications" else "Skip for now") } } } 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 0f30d8e..dab7067 100644 --- a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt +++ b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt @@ -83,8 +83,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) } navigationStack.add(savedDirectory) - // Check root access (don't request, just check - wizard handles requesting) - val hasRoot = RootManager.hasRoot() + // First check cached root status (no popup) - user may have granted in Magisk settings + val cachedRootStatus = RootManager.isRootGrantedCached() + + val hasRoot = when { + // If cached status says granted, root is available + cachedRootStatus == true -> true + // If cached status says denied, check if user wants to retry + cachedRootStatus == false -> false + // If unknown, check preference from wizard + else -> { + val prefs = getApplication() + .getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) + val rootGrantedInWizard = prefs.getBoolean("root_granted", false) + if (rootGrantedInWizard) { + RootManager.hasRoot() + } else { + false + } + } + } _uiState.update { it.copy(hasRoot = hasRoot) } if (hasRoot) { @@ -267,6 +285,72 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return navigationStack.size > 1 } + fun requestRootAccess() { + viewModelScope.launch { + val granted = RootManager.requestRoot() + if (granted) { + // Save to preferences + getApplication() + .getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) + .edit() + .putBoolean("root_granted", true) + .apply() + + // Re-initialize with root + _uiState.update { it.copy(hasRoot = true, rootDenied = false) } + isoDriveManager.initialize() + + val supportStatus = isoDriveManager.isSupported() + val isSupported = supportStatus == SupportStatus.CONFIGFS_SUPPORTED || + supportStatus == SupportStatus.SYSFS_SUPPORTED + + _uiState.update { it.copy(isSupported = isSupported) } + + if (isSupported) { + loadFiles() + checkMountStatus() + } + + _uiState.update { it.copy(successMessage = "Root access granted") } + } else { + // User denied root access + _uiState.update { it.copy(rootDenied = true) } + } + } + } + + fun refreshRootStatus() { + viewModelScope.launch { + // Check cached status (no popup) + val cachedStatus = RootManager.isRootGrantedCached() + if (cachedStatus == true && !_uiState.value.hasRoot) { + // Root was granted externally (e.g., in Magisk settings) + _uiState.update { it.copy(hasRoot = true, rootDenied = false) } + + // Save to preferences + getApplication() + .getSharedPreferences("iso_drive_prefs", android.content.Context.MODE_PRIVATE) + .edit() + .putBoolean("root_granted", true) + .apply() + + // Initialize if needed + isoDriveManager.initialize() + + val supportStatus = isoDriveManager.isSupported() + val isSupported = supportStatus == SupportStatus.CONFIGFS_SUPPORTED || + supportStatus == SupportStatus.SYSFS_SUPPORTED + + _uiState.update { it.copy(isSupported = isSupported) } + + if (isSupported) { + loadFiles() + checkMountStatus() + } + } + } + } + fun clearError() { _uiState.update { it.copy(errorMessage = null) } } @@ -279,6 +363,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { data class MainUiState( val isLoading: Boolean = true, val hasRoot: Boolean = false, + val rootDenied: Boolean = false, // True if user denied root in Magisk val isSupported: Boolean = false, val mountStatus: MountStatus = MountStatus.UNMOUNTED, val isoFiles: List = emptyList(),