fix permission poups and add settings to see permission state
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user