diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5c5237ae..dc04a2fdd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,8 +53,8 @@ android { defaultConfig { applicationId = "app.gamenative" - minSdk = 26 - targetSdk = 28 + minSdk = 28 + targetSdk = 31 versionCode = 9 versionName = "0.7.0" diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 74792a51f..9f4623fb9 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -1,36 +1,28 @@ package app.gamenative -import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.res.Configuration import android.graphics.Color.TRANSPARENT -import android.os.Build import android.os.Bundle -import android.util.Log import android.view.KeyEvent import android.view.MotionEvent import android.view.OrientationEventListener import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback import androidx.activity.SystemBarStyle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.CompositionLocalProvider -import androidx.lifecycle.lifecycleScope import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import coil.ImageLoader -import coil.disk.DiskCache -import coil.memory.MemoryCache -import coil.request.CachePolicy +import androidx.lifecycle.lifecycleScope import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService import app.gamenative.service.gog.GOGService @@ -41,16 +33,21 @@ import app.gamenative.utils.ContainerUtils import app.gamenative.utils.IconDecoder import app.gamenative.utils.IntentLaunchManager import app.gamenative.utils.LocaleHelper +import app.gamenative.utils.PermissionManager +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.CachePolicy import com.posthog.PostHog import com.skydoves.landscapist.coil.LocalCoilImageLoader import com.winlator.core.AppUtils import com.winlator.inputcontrols.ControllerManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import java.util.EnumSet -import kotlin.math.abs import okio.Path.Companion.toOkioPath import timber.log.Timber +import java.util.EnumSet +import kotlin.math.abs @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -148,6 +145,7 @@ class MainActivity : ComponentActivity() { setContent { var hasNotificationPermission by remember { mutableStateOf(false) } + val context = LocalContext.current val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { isGranted -> @@ -155,12 +153,11 @@ class MainActivity : ComponentActivity() { } LaunchedEffect(Unit) { - if (!hasNotificationPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + if (!hasNotificationPermission) { + PermissionManager.requestNotificationPermission(context, permissionLauncher) } } - val context = LocalContext.current val imageLoader = remember { val memoryCache = MemoryCache.Builder(context) .maxSizePercent(0.1) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index c5cceea05..82e84110b 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -1,27 +1,22 @@ package app.gamenative.ui.component.dialog -import android.widget.Toast -import android.widget.Spinner -import android.widget.ArrayAdapter +import android.content.Intent import android.content.res.Configuration +import android.widget.Toast 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.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -32,6 +27,7 @@ import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.material3.AlertDialog import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,11 +36,9 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -64,13 +58,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.tooling.preview.Preview import app.gamenative.R +import app.gamenative.service.SteamService import app.gamenative.ui.component.dialog.state.MessageDialogState import app.gamenative.ui.component.settings.SettingsCPUList import app.gamenative.ui.component.settings.SettingsCenteredLabel @@ -78,32 +73,28 @@ import app.gamenative.ui.component.settings.SettingsEnvVars import app.gamenative.ui.component.settings.SettingsListDropdown import app.gamenative.ui.component.settings.SettingsMultiListDropdown import app.gamenative.ui.components.rememberCustomGameFolderPicker -import app.gamenative.ui.components.requestPermissionsForPath import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt -import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.ContainerUtils -import app.gamenative.service.SteamService -import com.winlator.contents.ContentProfile -import com.winlator.contents.ContentsManager -import com.winlator.contents.AdrenotoolsManager +import app.gamenative.utils.PermissionManager import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsMenuLink import com.alorma.compose.settings.ui.SettingsSwitch import com.winlator.box86_64.Box86_64PresetManager import com.winlator.container.Container import com.winlator.container.ContainerData +import com.winlator.contents.AdrenotoolsManager +import com.winlator.contents.ContentProfile +import com.winlator.contents.ContentsManager +import com.winlator.core.DefaultVersion +import com.winlator.core.GPUHelper import com.winlator.core.KeyValueSet import com.winlator.core.StringUtils +import com.winlator.core.WineInfo.MAIN_WINE_VERSION import com.winlator.core.envvars.EnvVarInfo -import com.winlator.core.envvars.EnvVars import com.winlator.core.envvars.EnvVarSelectionType -import com.winlator.core.DefaultVersion -import com.winlator.core.GPUHelper -import com.winlator.core.WineInfo -import com.winlator.core.WineInfo.MAIN_WINE_VERSION -import com.winlator.fexcore.FEXCoreManager +import com.winlator.core.envvars.EnvVars import com.winlator.fexcore.FEXCorePresetManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -726,6 +717,9 @@ fun ContainerConfigDialog( val storagePermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), ) { } + val allFilesAccessLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { } val folderPicker = rememberCustomGameFolderPicker( onPathSelected = { path -> @@ -764,8 +758,12 @@ fun ContainerConfigDialog( } catch (_: Exception) { false } - if (!canAccess && !CustomGameScanner.hasStoragePermission(context, path)) { - requestPermissionsForPath(context, path, storagePermissionLauncher) + if (!canAccess && !PermissionManager.hasStorageAccessForPath(context, path)) { + PermissionManager.requestStorageAccess( + context, + storagePermissionLauncher, + allFilesAccessLauncher, + ) } config = config.copy(drives = "${config.drives}${letter}:${path}") diff --git a/app/src/main/java/app/gamenative/ui/components/CustomGameFolderPicker.kt b/app/src/main/java/app/gamenative/ui/components/CustomGameFolderPicker.kt index c070541cc..2d637573c 100644 --- a/app/src/main/java/app/gamenative/ui/components/CustomGameFolderPicker.kt +++ b/app/src/main/java/app/gamenative/ui/components/CustomGameFolderPicker.kt @@ -1,19 +1,15 @@ package app.gamenative.ui.components -import android.Manifest -import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.provider.DocumentsContract import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import app.gamenative.R -import app.gamenative.utils.CustomGameScanner /** * Converts a document tree URI to a file path. @@ -79,32 +75,6 @@ fun getPathFromTreeUri(uri: Uri?): String? { } } -/** - * Ensures we have the correct permissions for the provided path. - */ -fun requestPermissionsForPath( - context: Context, - path: String, - storagePermissionLauncher: ManagedActivityResultLauncher, Map>?, -) { - val isOutsideSandbox = !path.contains("/Android/data/${context.packageName}") && - !path.contains(context.dataDir.path) - - if (!isOutsideSandbox) { - return - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - CustomGameScanner.requestManageExternalStoragePermission(context) - } else { - val permissions = arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) - storagePermissionLauncher?.launch(permissions) - } -} - data class CustomGameFolderPicker( val launchPicker: () -> Unit, ) @@ -141,4 +111,3 @@ fun rememberCustomGameFolderPicker( ) } } - diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 50fa714b4..9eb3b8cbd 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -8,23 +8,25 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.displayCutoutPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,58 +34,36 @@ import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.setValue -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.gamenative.PrefManager import app.gamenative.R -import app.gamenative.data.LibraryItem +import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource -import app.gamenative.service.SteamService +import app.gamenative.data.LibraryItem +import app.gamenative.ui.components.rememberCustomGameFolderPicker import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter -import app.gamenative.ui.enums.Orientation -import app.gamenative.events.AndroidEvent -import app.gamenative.PluviaApp -import app.gamenative.data.GameCompatibilityStatus import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.model.LibraryViewModel import app.gamenative.ui.screen.library.components.LibraryDetailPane import app.gamenative.ui.screen.library.components.LibraryListPane import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.components.rememberCustomGameFolderPicker -import app.gamenative.ui.components.requestPermissionsForPath -import app.gamenative.utils.CustomGameScanner -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.EnumSet +import app.gamenative.utils.PermissionManager @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -155,6 +135,9 @@ private fun LibraryScreenContent( val storagePermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), ) { } + val allFilesAccessLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { } val folderPicker = rememberCustomGameFolderPicker( onPathSelected = { path -> @@ -170,8 +153,12 @@ private fun LibraryScreenContent( // Only request permissions if we can't access the folder AND it's outside the sandbox // (folders selected via OpenDocumentTree should already be accessible) - if (!canAccess && !CustomGameScanner.hasStoragePermission(context, path)) { - requestPermissionsForPath(context, path, storagePermissionLauncher) + if (!canAccess && !PermissionManager.hasStorageAccessForPath(context, path)) { + PermissionManager.requestStorageAccess( + context, + storagePermissionLauncher, + allFilesAccessLauncher, + ) } onAddCustomGameFolder(path) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 2c6abeeb3..824a3452a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -1,8 +1,7 @@ package app.gamenative.ui.screen.library.appscreen -import android.Manifest import android.content.Context -import android.content.pm.PackageManager +import android.os.Build import android.os.Environment import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -10,21 +9,34 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.core.content.ContextCompat +import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.LibraryItem import app.gamenative.enums.Marker import app.gamenative.enums.PathType import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent -import app.gamenative.PluviaApp import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirPath +import app.gamenative.ui.component.dialog.GameManagerDialog import app.gamenative.ui.component.dialog.MessageDialog +import app.gamenative.ui.component.dialog.state.GameManagerDialogState import app.gamenative.ui.component.dialog.state.MessageDialogState import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo @@ -36,23 +48,19 @@ import app.gamenative.utils.ContainerUtils import app.gamenative.utils.GameCompatibilityCache import app.gamenative.utils.GameCompatibilityService import app.gamenative.utils.MarkerUtils -import app.gamenative.utils.StorageUtils +import app.gamenative.utils.PermissionManager import app.gamenative.utils.SteamUtils -import com.posthog.PostHog +import app.gamenative.utils.StorageUtils import com.google.android.play.core.splitcompat.SplitCompat +import com.posthog.PostHog import com.winlator.container.ContainerData import com.winlator.container.ContainerManager -import com.winlator.fexcore.FEXCoreManager +import com.winlator.core.GPUInformation import com.winlator.xenvironment.ImageFsInstaller import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import app.gamenative.ui.component.dialog.GameManagerDialog -import app.gamenative.ui.component.dialog.state.GameManagerDialogState -import com.winlator.core.GPUInformation import timber.log.Timber import java.nio.file.Paths import kotlin.io.path.pathString @@ -318,7 +326,7 @@ class SteamAppScreen : BaseAppScreen() { override fun isDownloading(context: Context, libraryItem: LibraryItem): Boolean { val downloadInfo = SteamService.getAppDownloadInfo(libraryItem.gameId) - return downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + return downloadInfo != null && downloadInfo.getProgress() < 1f } override fun getDownloadProgress(context: Context, libraryItem: LibraryItem): Float { @@ -337,7 +345,7 @@ class SteamAppScreen : BaseAppScreen() { onStateChanged: () -> Unit, onProgressChanged: (Float) -> Unit, onHasPartialDownloadChanged: ((Boolean) -> Unit)? - ): (() -> Unit)? { + ): () -> Unit { val appId = libraryItem.gameId val disposables = mutableListOf<() -> Unit>() @@ -430,7 +438,7 @@ class SteamAppScreen : BaseAppScreen() { ) { val gameId = libraryItem.gameId val downloadInfo = SteamService.getAppDownloadInfo(gameId) - val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val isDownloading = downloadInfo != null && downloadInfo.getProgress() < 1f val isInstalled = SteamService.isAppInstalled(gameId) if (isDownloading) { @@ -474,7 +482,7 @@ class SteamAppScreen : BaseAppScreen() { override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) { val gameId = libraryItem.gameId val downloadInfo = SteamService.getAppDownloadInfo(gameId) - val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val isDownloading = downloadInfo != null && downloadInfo.getProgress() < 1f if (isDownloading) { downloadInfo?.cancel() @@ -489,7 +497,7 @@ class SteamAppScreen : BaseAppScreen() { val gameId = libraryItem.gameId val isInstalled = SteamService.isAppInstalled(gameId) val downloadInfo = SteamService.getAppDownloadInfo(gameId) - val isDownloading = downloadInfo != null && (downloadInfo.getProgress() ?: 0f) < 1f + val isDownloading = downloadInfo != null && downloadInfo.getProgress() < 1f if (isDownloading || SteamService.hasPartialDownload(gameId)) { // Show cancel download dialog when downloading @@ -876,39 +884,39 @@ class SteamAppScreen : BaseAppScreen() { val oldGamesDirectory = remember { Paths.get(SteamService.defaultAppInstallPath).pathString } - val initialStoragePermissionGranted = remember { - val writePermissionGranted = ContextCompat.checkSelfPermission( - context, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - val readPermissionGranted = ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - writePermissionGranted && readPermissionGranted - } + val initialStoragePermissionGranted = remember { PermissionManager.hasStorageAccess(context) } var hasStoragePermission by remember { mutableStateOf(initialStoragePermissionGranted) } var installSizeInfo by remember(gameId) { mutableStateOf(null) } // Permission launcher for game migration val permissionMovingInternalLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), - onResult = { permission -> - scope.launch { - showMoveDialog = true - StorageUtils.moveGamesFromOldPath( - Paths.get(Environment.getExternalStorageDirectory().absolutePath, "GameNative", "Steam").pathString, - oldGamesDirectory, - onProgressUpdate = { currentFile, fileProgress, movedFiles, totalFiles -> - current = currentFile - progress = fileProgress - moved = movedFiles - total = totalFiles - }, - onComplete = { - showMoveDialog = false - }, - ) + + onResult = { permissions -> + val allGranted = permissions.values.all { it } + if (allGranted) { + scope.launch { + showMoveDialog = true + StorageUtils.moveGamesFromOldPath( + Paths.get(Environment.getExternalStorageDirectory().absolutePath, "GameNative", "Steam").pathString, + oldGamesDirectory, + onProgressUpdate = { currentFile, fileProgress, movedFiles, totalFiles -> + current = currentFile + progress = fileProgress + moved = movedFiles + total = totalFiles + }, + onComplete = { + showMoveDialog = false + }, + ) + } + } else { + Toast.makeText( + context, + context.getString(R.string.steam_storage_permission_required), + Toast.LENGTH_SHORT + ).show() } }, ) @@ -917,11 +925,8 @@ class SteamAppScreen : BaseAppScreen() { val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> - val writePermissionGranted = permissions[Manifest.permission.WRITE_EXTERNAL_STORAGE] ?: false - val readPermissionGranted = permissions[Manifest.permission.READ_EXTERNAL_STORAGE] ?: false - val granted = writePermissionGranted && readPermissionGranted - hasStoragePermission = granted - if (!granted) { + hasStoragePermission = PermissionManager.hasStorageAccess(context) + if (!hasStoragePermission) { // Permissions denied Toast.makeText( context, @@ -932,6 +937,11 @@ class SteamAppScreen : BaseAppScreen() { hideGameManagerDialog(gameId) } } + val allFilesAccessLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + hasStoragePermission = PermissionManager.hasStorageAccess(context) + } LaunchedEffect(gameId, hasStoragePermission) { if (!hasStoragePermission) { @@ -962,17 +972,28 @@ class SteamAppScreen : BaseAppScreen() { } } + fun requestStorageAccessIfNeeded(onDenied: () -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val launched = PermissionManager.requestAllFilesAccess(context, allFilesAccessLauncher) + if (!launched) { + Toast.makeText( + context, + context.getString(R.string.steam_storage_permission_required), + Toast.LENGTH_SHORT + ).show() + onDenied() + } + } else { + PermissionManager.requestStorageAccess(context, permissionLauncher, allFilesAccessLauncher) + } + } + LaunchedEffect(installDialogState.visible, installDialogState.type, hasStoragePermission, installSizeInfo) { if (!installDialogState.visible) return@LaunchedEffect if (installDialogState.type != DialogType.INSTALL_APP_PENDING) return@LaunchedEffect if (!hasStoragePermission) { - permissionLauncher.launch( - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ), - ) + requestStorageAccessIfNeeded { hideInstallDialog(gameId) } } else { val info = installSizeInfo ?: return@LaunchedEffect val state = if (info.availableBytes < info.installBytes) { @@ -987,21 +1008,16 @@ class SteamAppScreen : BaseAppScreen() { LaunchedEffect(gameManagerDialogState.visible, hasStoragePermission) { if (!gameManagerDialogState.visible) return@LaunchedEffect if (!hasStoragePermission) { - permissionLauncher.launch( - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ), - ) + requestStorageAccessIfNeeded { hideGameManagerDialog(gameId) } } } // Install dialog (INSTALL_APP, NOT_ENOUGH_SPACE, CANCEL_APP_DOWNLOAD) if (installDialogState.visible) { - val onDismissRequest: (() -> Unit)? = { + val onDismissRequest: () -> Unit = { hideInstallDialog(gameId) } - val onDismissClick: (() -> Unit)? = { + val onDismissClick: () -> Unit = { hideInstallDialog(gameId) } val onConfirmClick: (() -> Unit)? = when (installDialogState.type) { @@ -1254,4 +1270,3 @@ class SteamAppScreen : BaseAppScreen() { } } } - diff --git a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt index 54a90a9d9..b90e92d1e 100644 --- a/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt +++ b/app/src/main/java/app/gamenative/utils/CustomGameScanner.kt @@ -1,25 +1,15 @@ package app.gamenative.utils import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.Settings -import androidx.core.content.ContextCompat -import android.content.pm.PackageManager -import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem -import app.gamenative.events.AndroidEvent import app.gamenative.service.DownloadService import com.winlator.container.ContainerManager -import java.io.File -import kotlin.math.abs import kotlinx.coroutines.launch import timber.log.Timber -import org.json.JSONObject +import java.io.File +import kotlin.math.abs object CustomGameScanner { @@ -352,63 +342,6 @@ object CustomGameScanner { return candidates.distinct() } - /** - * Checks if we have permission to access a given path. - * On Android 11+ (API 30+), this checks for MANAGE_EXTERNAL_STORAGE permission. - * On older versions, checks for READ_EXTERNAL_STORAGE. - */ - fun hasStoragePermission(context: Context, path: String): Boolean { - // Check if path is outside app sandbox - val isOutsideSandbox = !path.contains("/Android/data/${context.packageName}") && - !path.contains(context.dataDir.path) - - if (!isOutsideSandbox) { - // Path is in app sandbox, no special permission needed - return true - } - - // For paths outside sandbox, check permissions - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11+ requires MANAGE_EXTERNAL_STORAGE for broad access - return Environment.isExternalStorageManager() - } else { - // Android 10 and below use standard storage permissions - return ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.READ_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - } - } - - /** - * Opens the Android settings page to grant MANAGE_EXTERNAL_STORAGE permission. - * This is required for Android 11+ to access paths outside the app sandbox. - * Returns true if the intent was launched, false otherwise. - */ - fun requestManageExternalStoragePermission(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) - intent.data = Uri.parse("package:${context.packageName}") - context.startActivity(intent) - return true - } catch (e: Exception) { - Timber.tag("CustomGameScanner").e(e, "Failed to open settings for MANAGE_EXTERNAL_STORAGE") - // Fallback: try generic app settings - try { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.parse("package:${context.packageName}") - context.startActivity(intent) - return true - } catch (e2: Exception) { - Timber.tag("CustomGameScanner").e(e2, "Failed to open app settings") - return false - } - } - } - return false - } - /** * All manually added folders are included regardless of content. * Optionally filter by [query] contained in folder name (case-insensitive). @@ -427,7 +360,7 @@ object CustomGameScanner { val folderName = File(manualPath).name if (!folderName.contains(q, ignoreCase = true)) continue } - + val manualItem = createLibraryItemFromFolder(manualPath) if (manualItem != null && existingAppIds.add(manualItem.appId)) { items.add(manualItem.copy(index = indexCounter++)) diff --git a/app/src/main/java/app/gamenative/utils/PermissionManager.kt b/app/src/main/java/app/gamenative/utils/PermissionManager.kt new file mode 100644 index 000000000..bfe4bccb4 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/PermissionManager.kt @@ -0,0 +1,114 @@ +package app.gamenative.utils + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult +import androidx.core.content.ContextCompat +import timber.log.Timber + +object PermissionManager { + private const val NOTIFICATION_PERMISSION = Manifest.permission.POST_NOTIFICATIONS + private val legacyStoragePermissions = arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + + fun hasStorageAccess(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + val readGranted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + val writeGranted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + readGranted && writeGranted + } + } + + fun hasStorageAccessForPath(context: Context, path: String): Boolean { + val isOutsideSandbox = !path.contains("/Android/data/${context.packageName}") && + !path.contains(context.dataDir.path) + if (!isOutsideSandbox) { + return true + } + return hasStorageAccess(context) + } + + fun requestStorageAccess( + context: Context, + legacyPermissionLauncher: ManagedActivityResultLauncher, Map>?, + allFilesAccessLauncher: ManagedActivityResultLauncher? = null, + ): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + requestAllFilesAccess(context, allFilesAccessLauncher) + } else { + legacyPermissionLauncher?.let { + it.launch(legacyStoragePermissions) + true + } ?: false + } + } + + fun requestAllFilesAccess( + context: Context, + launcher: ManagedActivityResultLauncher? = null, + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + try { + // val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + // intent.data = Uri.parse("package:${context.packageName}") + // if (launcher != null) { + // launcher.launch(intent) + // } else { + // context.startActivity(intent) + // } + return true + } catch (e: Exception) { + Timber.tag("PermissionManager").e(e, "Failed to open settings for all files access") + return try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:${context.packageName}") + if (launcher != null) { + launcher.launch(intent) + } else { + context.startActivity(intent) + } + true + } catch (e2: Exception) { + Timber.tag("PermissionManager").e(e2, "Failed to open app settings") + false + } + } + } + + fun hasNotificationPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + context, + NOTIFICATION_PERMISSION + ) == PackageManager.PERMISSION_GRANTED + } + + fun requestNotificationPermission( + context: Context, + launcher: ManagedActivityResultLauncher?, + ): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false + if (hasNotificationPermission(context)) return true + return launcher?.let { + it.launch(NOTIFICATION_PERMISSION) + true + } ?: false + } +}