fix permission poups and add settings to see permission state

This commit is contained in:
2026-03-10 03:59:51 +05:00
parent 86bf14f9d9
commit ffdb600c1c
4 changed files with 331 additions and 13 deletions

View File

@@ -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) {

View File

@@ -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
)
}
}
}

View File

@@ -130,14 +130,25 @@ private fun RootAccessStep(
onNext: () -> Unit,
onSkip: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var hasRoot by remember { mutableStateOf<Boolean?>(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")
}
}
}

View File

@@ -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<android.app.Application>()
.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<android.app.Application>()
.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<android.app.Application>()
.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<IsoFile> = emptyList(),