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()
} }
suspend fun requestRoot(): Boolean = withContext(Dispatchers.IO) { /**
* 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 Shell.getShell().isRoot
} catch (e: Exception) {
false
}
}
/**
* Request root access. Will trigger Magisk popup.
*/
suspend fun requestRoot(): Boolean = withContext(Dispatchers.IO) {
try {
Shell.getShell().isRoot
} catch (e: Exception) {
false
}
} }
suspend fun executeCommand(command: String): CommandResult = withContext(Dispatchers.IO) { suspend fun executeCommand(command: String): CommandResult = withContext(Dispatchers.IO) {

View File

@@ -1,7 +1,11 @@
package sh.sar.isodroid.ui.screens package sh.sar.isodroid.ui.screens
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Code
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info 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.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -40,9 +49,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp 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 import sh.sar.isodroid.viewmodel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -53,9 +66,46 @@ fun SettingsScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val activity = context as? androidx.activity.ComponentActivity
var showPathDialog by remember { mutableStateOf(false) } var showPathDialog by remember { mutableStateOf(false) }
var tempPath by remember(uiState.currentPath) { mutableStateOf(uiState.isoDirectory) } 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) { fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent) context.startActivity(intent)
@@ -98,6 +148,61 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) 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 // About section
SectionHeader(title = "About") SectionHeader(title = "About")
@@ -312,7 +417,7 @@ private fun SectionHeader(title: String) {
@Composable @Composable
private fun SettingsItem( private fun SettingsItem(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: ImageVector,
title: String, title: String,
subtitle: String, subtitle: String,
onClick: () -> Unit 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, onNext: () -> Unit,
onSkip: () -> Unit onSkip: () -> Unit
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var hasRoot by remember { mutableStateOf<Boolean?>(null) } var hasRoot by remember { mutableStateOf<Boolean?>(null) }
var isChecking by remember { mutableStateOf(false) } 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( PermissionCard(
icon = Icons.Default.Security, icon = Icons.Default.Security,
title = "Root Access", 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 granted = hasRoot
) )
@@ -150,12 +161,27 @@ private fun RootAccessStep(
) { ) {
Text("Continue") 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 { } else {
Button( Button(
onClick = { onClick = {
isChecking = true isChecking = true
scope.launch { scope.launch {
hasRoot = RootManager.requestRoot() val granted = RootManager.requestRoot()
hasRoot = granted
if (granted) {
saveRootGranted(true)
}
isChecking = false isChecking = false
} }
}, },
@@ -168,7 +194,10 @@ private fun RootAccessStep(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OutlinedButton( OutlinedButton(
onClick = onSkip, onClick = {
saveRootGranted(false)
onSkip()
},
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Skip for now") Text("Skip for now")
@@ -182,6 +211,7 @@ private fun NotificationStep(
onSkip: () -> Unit onSkip: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? android.app.Activity
// Check if we need to show this step at all (only Android 13+) // Check if we need to show this step at all (only Android 13+)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@@ -198,18 +228,34 @@ private fun NotificationStep(
) == PackageManager.PERMISSION_GRANTED ) == 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( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { granted -> ) { granted ->
hasPermission = 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( PermissionCard(
icon = Icons.Default.Notifications, icon = Icons.Default.Notifications,
title = "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.", description = when {
granted = hasPermission 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)) Spacer(modifier = Modifier.height(32.dp))
@@ -221,6 +267,14 @@ private fun NotificationStep(
) { ) {
Text("Continue") Text("Continue")
} }
} else if (permanentlyDenied) {
// Android won't show the popup again
Button(
onClick = onSkip,
modifier = Modifier.fillMaxWidth()
) {
Text("Continue Without Notifications")
}
} else { } else {
Button( Button(
onClick = { onClick = {
@@ -228,7 +282,7 @@ private fun NotificationStep(
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Enable Notifications") Text(if (wasDenied) "Try Again" else "Enable Notifications")
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -237,7 +291,7 @@ private fun NotificationStep(
onClick = onSkip, onClick = onSkip,
modifier = Modifier.fillMaxWidth() 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) } _uiState.update { it.copy(isoDirectory = savedDirectory, currentPath = savedDirectory) }
navigationStack.add(savedDirectory) navigationStack.add(savedDirectory)
// Check root access (don't request, just check - wizard handles requesting) // First check cached root status (no popup) - user may have granted in Magisk settings
val hasRoot = RootManager.hasRoot() 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) } _uiState.update { it.copy(hasRoot = hasRoot) }
if (hasRoot) { if (hasRoot) {
@@ -267,6 +285,72 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return navigationStack.size > 1 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() { fun clearError() {
_uiState.update { it.copy(errorMessage = null) } _uiState.update { it.copy(errorMessage = null) }
} }
@@ -279,6 +363,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
data class MainUiState( data class MainUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val hasRoot: Boolean = false, val hasRoot: Boolean = false,
val rootDenied: Boolean = false, // True if user denied root in Magisk
val isSupported: Boolean = false, val isSupported: Boolean = false,
val mountStatus: MountStatus = MountStatus.UNMOUNTED, val mountStatus: MountStatus = MountStatus.UNMOUNTED,
val isoFiles: List<IsoFile> = emptyList(), val isoFiles: List<IsoFile> = emptyList(),