compose: onboarding: Initial permissions screen work

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-09-08 17:15:44 +08:00
parent 52d6c9ce56
commit 88ba2f4718
12 changed files with 410 additions and 52 deletions

View File

@@ -29,14 +29,12 @@ import com.aurora.store.data.model.PermissionType
* Composable to display permission details in a list
* @param modifier The modifier to be applied to the composable
* @param permission [Permission] to display
* @param isGranted If the permission has been granted
* @param onAction Callback when the user clicks the action button
*/
@Composable
fun PermissionComposable(
modifier: Modifier = Modifier,
permission: Permission,
isGranted: Boolean = false,
onAction: () -> Unit = {}
) {
Row(
@@ -58,9 +56,9 @@ fun PermissionComposable(
style = MaterialTheme.typography.bodySmall
)
}
TextButton(onClick = onAction, enabled = !isGranted) {
TextButton(onClick = onAction, enabled = !permission.isGranted) {
Text(
text = if (isGranted) {
text = if (permission.isGranted) {
stringResource(R.string.action_granted)
} else {
stringResource(R.string.action_grant)

View File

@@ -5,8 +5,6 @@
package com.aurora.store.compose.composables
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
@@ -27,24 +25,21 @@ import com.aurora.store.R
* @see ActionHeaderComposable
*/
@Composable
fun TextDividerComposable(modifier: Modifier = Modifier, @StringRes title: Int) {
Row(
fun TextDividerComposable(modifier: Modifier = Modifier, title: String) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.padding_small))
) {
Text(
text = stringResource(title).uppercase(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
.padding(dimensionResource(R.dimen.padding_small)),
text = title.uppercase(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@Preview(showBackground = true)
@Composable
private fun TextDividerComposablePreview() {
TextDividerComposable(title = R.string.item_optional)
TextDividerComposable(title = stringResource(R.string.item_optional))
}

View File

@@ -18,6 +18,7 @@ import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator
import com.aurora.store.compose.ui.commons.BlacklistScreen
import com.aurora.store.compose.ui.details.AppDetailsScreen
import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.compose.ui.onboarding.PermissionsScreen
import com.aurora.store.compose.ui.search.SearchScreen
/**
@@ -69,6 +70,12 @@ fun NavDisplay(startDestination: NavKey) {
}
)
}
entry<Screen.Permissions> { screen ->
PermissionsScreen(
onNavigateUp = { onNavigateUp() },
)
}
}
)
}

View File

@@ -32,4 +32,7 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data object Search : Screen()
@Serializable
data object Permissions : Screen()
}

View File

@@ -77,10 +77,12 @@ import com.aurora.store.compose.ui.details.components.Tags
import com.aurora.store.compose.ui.details.components.Testing
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.compose.ui.onboarding.PermissionsScreen
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.AppState
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.providers.PermissionProvider.Companion.isPermittedToInstall
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.ShortcutManagerUtil
import com.aurora.store.viewmodel.details.AppDetailsViewModel
@@ -237,6 +239,16 @@ private fun ScreenContentApp(
}
}
fun onInstall() {
if (isPermittedToInstall(context, app)) {
onDownload()
onNavigateBack()
} else {
context.toast(R.string.permissions_denied)
showExtraPane(Screen.Permissions)
}
}
@Composable
fun SetupMenu() {
AppDetailsMenu(isInstalled = app.isInstalled, isFavorite = isFavorite) { menuItem ->
@@ -271,7 +283,7 @@ private fun ScreenContentApp(
Actions(
primaryActionDisplayName = stringResource(R.string.action_update),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = onDownload,
onPrimaryAction = ::onInstall,
onSecondaryAction = onUninstall
)
}
@@ -295,7 +307,7 @@ private fun ScreenContentApp(
Actions(
primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
onPrimaryAction = onDownload,
onPrimaryAction = ::onInstall,
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
)
}
@@ -476,6 +488,11 @@ private fun ScreenContentApp(
onNavigateToAppDetails = { onNavigateToAppDetails(it) }
)
is Screen.Permissions -> PermissionsScreen(
onNavigateUp = ::onNavigateBack,
onPermissionCallback = { onInstall() }
)
else -> {}
}
}

View File

@@ -0,0 +1,202 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.onboarding
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.provider.Settings
import android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.adaptiveNavigationIcon
import com.aurora.extensions.isTAndAbove
import com.aurora.extensions.toast
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.compose.composables.PermissionComposable
import com.aurora.store.compose.composables.TextDividerComposable
import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.data.model.Permission
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import com.aurora.store.util.PackageUtil
import com.aurora.store.viewmodel.onboarding.PermissionsViewModel
import kotlin.random.Random
private const val TAG = "PermissionsScreen"
@Composable
fun PermissionsScreen(
isOnboarding: Boolean = false,
onNavigateUp: () -> Unit,
onPermissionCallback: (type: PermissionType) -> Unit = {},
viewModel: PermissionsViewModel = hiltViewModel()
) {
val permissions by viewModel.permissions.collectAsStateWithLifecycle()
ScreenContent(
isOnboarding = isOnboarding,
permissions = permissions,
onNavigateUp = onNavigateUp,
onPermissionCallback = { type ->
viewModel.refreshPermissionsList()
onPermissionCallback(type)
}
)
}
@Composable
private fun ScreenContent(
isOnboarding: Boolean = false,
permissions: List<Permission> = emptyList(),
onNavigateUp: () -> Unit = {},
onPermissionCallback: (type: PermissionType) -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo()
) {
val context = LocalContext.current
var permissionRequested by rememberSaveable { mutableStateOf<PermissionType?>(null) }
@SuppressLint("InlinedApi", "BatteryLife")
val intentMap = mapOf(
PermissionType.STORAGE_MANAGER to PackageUtil.getStorageManagerIntent(context),
PermissionType.INSTALL_UNKNOWN_APPS to PackageUtil.getInstallUnknownAppsIntent(),
PermissionType.DOZE_WHITELIST to Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${BuildConfig.APPLICATION_ID}".toUri()
),
PermissionType.APP_LINKS to Intent(
ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
"package:${BuildConfig.APPLICATION_ID}".toUri()
)
)
val intentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { permissionRequested?.let { onPermissionCallback(it) } }
)
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = {
when (permissionRequested) {
PermissionType.STORAGE_MANAGER -> {
intentLauncher.launch(intentMap[PermissionType.STORAGE_MANAGER]!!)
}
else -> permissionRequested?.let { onPermissionCallback(it) }
}
}
)
fun requestPermission(type: PermissionType) {
try {
permissionRequested = type
when (type) {
PermissionType.EXTERNAL_STORAGE -> {
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
PermissionType.POST_NOTIFICATIONS -> {
if (isTAndAbove) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
PermissionType.STORAGE_MANAGER -> {
if (!isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS)) {
context.toast(R.string.toast_permission_installer_required)
} else {
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
else -> {
val intent = intentMap[type] ?: return
intentLauncher.launch(intent)
}
}
} catch (exception: Exception) {
Log.e(TAG, "Error requesting permission", exception)
permissionRequested = null
}
}
Scaffold(
topBar = {
TopAppBarComposable(
title = stringResource(R.string.onboarding_title_permissions),
navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon,
onNavigateUp = if (isOnboarding) null else onNavigateUp
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall))
) {
permissions.sortedBy { it.optional }
.groupBy { permission -> permission.optional }
.forEach { (key, value) ->
stickyHeader {
TextDividerComposable(
title = if (!key) {
stringResource(R.string.item_required)
} else {
stringResource(R.string.item_optional)
}
)
}
items(items = value, key = { p -> p.type.name }) { permission ->
PermissionComposable(
permission = permission,
onAction = { requestPermission(permission.type) }
)
}
}
}
}
}
@Preview
@Composable
private fun PermissionsScreenPreview() {
val permissions = PermissionType.entries.map { type ->
Permission(
type = type,
title = LoremIpsum(3).values.first(),
subtitle = LoremIpsum(7).values.first(),
optional = Random.nextBoolean(),
isGranted = Random.nextBoolean()
)
}
ScreenContent(permissions = permissions)
}

View File

@@ -24,6 +24,7 @@ data class Permission(
val title: String,
val subtitle: String,
val optional: Boolean = false,
val isGranted: Boolean = false
) {
override fun equals(other: Any?): Boolean {
return when (other) {

View File

@@ -19,7 +19,9 @@ import com.aurora.extensions.isDomainVerified
import com.aurora.extensions.isExternalStorageAccessible
import com.aurora.extensions.isIgnoringBatteryOptimizations
import com.aurora.extensions.isTAndAbove
import com.aurora.extensions.requiresObbDir
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.data.model.PermissionType
@@ -28,6 +30,52 @@ import com.aurora.store.util.PackageUtil
class PermissionProvider(private val fragment: Fragment) :
ActivityResultCallback<ActivityResult> {
companion object {
/**
* Checks if Aurora Store has permissions to install the given app
*/
fun isPermittedToInstall(context: Context, app: App): Boolean {
if (!isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS)) return false
return when {
app.fileList.requiresObbDir() -> {
return isGranted(context, PermissionType.STORAGE_MANAGER)
}
else -> true
}
}
/**
* Checks whether a known permission has been granted
*/
fun isGranted(context: Context, permissionType: PermissionType): Boolean {
return when (permissionType) {
PermissionType.EXTERNAL_STORAGE,
PermissionType.STORAGE_MANAGER -> {
context.isExternalStorageAccessible()
}
PermissionType.INSTALL_UNKNOWN_APPS -> {
PackageUtil.canRequestPackageInstalls(context)
}
PermissionType.POST_NOTIFICATIONS -> {
if (isTAndAbove) {
context.checkManifestPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
true
}
}
PermissionType.DOZE_WHITELIST -> context.isIgnoringBatteryOptimizations()
PermissionType.APP_LINKS -> context.isDomainVerified("play.google.com") &&
context.isDomainVerified("market.android.com")
}
}
}
private val TAG = PermissionProvider::class.java.simpleName
private val context: Context
@@ -47,7 +95,7 @@ class PermissionProvider(private val fragment: Fragment) :
}
override fun onActivityResult(result: ActivityResult) {
permissionRequested?.let { permissionCallback(isGranted(it)) }
permissionRequested?.let { permissionCallback(isGranted(context, it)) }
}
fun request(permissionType: PermissionType, callback: (Boolean) -> Unit = {}) {
@@ -67,7 +115,7 @@ class PermissionProvider(private val fragment: Fragment) :
}
PermissionType.STORAGE_MANAGER -> {
if (!isGranted(PermissionType.INSTALL_UNKNOWN_APPS)) {
if (!isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS)) {
context.toast(R.string.toast_permission_installer_required)
} else {
/**
@@ -100,30 +148,6 @@ class PermissionProvider(private val fragment: Fragment) :
}
}
fun isGranted(permissionType: PermissionType): Boolean {
return when (permissionType) {
PermissionType.EXTERNAL_STORAGE,
PermissionType.STORAGE_MANAGER -> {
context.isExternalStorageAccessible()
}
PermissionType.INSTALL_UNKNOWN_APPS -> PackageUtil.canRequestPackageInstalls(context)
PermissionType.POST_NOTIFICATIONS -> {
if (isTAndAbove) {
context.checkManifestPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
true
}
}
PermissionType.DOZE_WHITELIST -> context.isIgnoringBatteryOptimizations()
PermissionType.APP_LINKS -> context.isDomainVerified("play.google.com") &&
context.isDomainVerified("market.android.com")
}
}
fun unregister() {
intentLauncher.unregister()
permissionLauncher.unregister()

View File

@@ -33,6 +33,7 @@ import com.aurora.extensions.isTAndAbove
import com.aurora.store.R
import com.aurora.store.data.model.Permission
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import com.aurora.store.databinding.FragmentOnboardingPermissionsBinding
import com.aurora.store.view.epoxy.views.TextDividerViewModel_
import com.aurora.store.view.epoxy.views.preference.PermissionViewModel_
@@ -160,7 +161,7 @@ class PermissionsFragment : BaseFragment<FragmentOnboardingPermissionsBinding>()
return PermissionViewModel_()
.id(permission.type.name)
.permission(permission)
.isGranted(permissionProvider.isGranted(permission.type))
.isGranted(isGranted(requireContext(), permission.type))
.click { _ ->
permissionProvider.request(permission.type) {
if (it) updateController()

View File

@@ -31,6 +31,7 @@ import androidx.preference.SwitchPreferenceCompat
import com.aurora.store.R
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.model.UpdateMode
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS
import com.aurora.store.util.Preferences.PREFERENCE_FILTER_AURORA_ONLY
@@ -63,7 +64,7 @@ class UpdatesPreference : BasePreferenceFragment() {
}
UpdateMode.CHECK_AND_NOTIFY -> {
if (permissionProvider.isGranted(PermissionType.POST_NOTIFICATIONS)) {
if (isGranted(requireContext(), PermissionType.POST_NOTIFICATIONS)) {
handleAutoUpdateDependentPrefs(true)
viewModel.updateHelper.scheduleAutomatedCheck()
true
@@ -81,7 +82,7 @@ class UpdatesPreference : BasePreferenceFragment() {
}
UpdateMode.CHECK_AND_INSTALL -> {
if (permissionProvider.isGranted(PermissionType.DOZE_WHITELIST)) {
if (isGranted(requireContext(), PermissionType.DOZE_WHITELIST)) {
handleAutoUpdateDependentPrefs(true)
viewModel.updateHelper.scheduleAutomatedCheck()
true

View File

@@ -37,6 +37,7 @@ import com.aurora.store.R
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.update.Update
import com.aurora.store.databinding.FragmentUpdatesBinding
@@ -192,7 +193,7 @@ class UpdatesFragment : BaseFragment<FragmentUpdatesBinding>() {
private fun updateSingle(update: Update) {
if (update.fileList.requiresObbDir()) {
if (permissionProvider.isGranted(PermissionType.STORAGE_MANAGER)) {
if (isGranted(requireContext(), PermissionType.STORAGE_MANAGER)) {
viewModel.download(update)
} else {
permissionProvider.request(PermissionType.STORAGE_MANAGER) {
@@ -207,7 +208,7 @@ class UpdatesFragment : BaseFragment<FragmentUpdatesBinding>() {
private fun updateAll() {
viewModel.updateAllEnqueued = true
if (viewModel.updates.value?.any { it.fileList.requiresObbDir() } == true) {
if (permissionProvider.isGranted(PermissionType.STORAGE_MANAGER)) {
if (isGranted(requireContext(), PermissionType.STORAGE_MANAGER)) {
viewModel.downloadAll()
} else {
permissionProvider.request(PermissionType.STORAGE_MANAGER) {

View File

@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.viewmodel.onboarding
import android.content.Context
import androidx.lifecycle.ViewModel
import com.aurora.extensions.isOAndAbove
import com.aurora.extensions.isRAndAbove
import com.aurora.extensions.isSAndAbove
import com.aurora.extensions.isTAndAbove
import com.aurora.store.R
import com.aurora.store.data.model.Permission
import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class PermissionsViewModel @Inject constructor(
@ApplicationContext private val context: Context
) : ViewModel() {
private val _permissions = MutableStateFlow(getPermissions(context))
val permissions = _permissions.asStateFlow()
private fun getPermissions(context: Context): List<Permission> {
val list = mutableListOf(
Permission(
type = PermissionType.INSTALL_UNKNOWN_APPS,
title = context.getString(R.string.onboarding_permission_installer),
subtitle = if (isOAndAbove) {
context.getString(R.string.onboarding_permission_installer_desc)
} else {
context.getString(R.string.onboarding_permission_installer_legacy_desc)
},
optional = false,
isGranted = isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS)
),
Permission(
type = PermissionType.DOZE_WHITELIST,
title = context.getString(R.string.onboarding_permission_doze),
subtitle = context.getString(R.string.onboarding_permission_doze_desc),
optional = true,
isGranted = isGranted(context, PermissionType.DOZE_WHITELIST)
)
)
if (isRAndAbove) {
list.add(
Permission(
type = PermissionType.STORAGE_MANAGER,
title = context.getString(R.string.onboarding_permission_esm),
subtitle = context.getString(R.string.onboarding_permission_esa_desc),
optional = false,
isGranted = isGranted(context, PermissionType.STORAGE_MANAGER)
)
)
} else {
list.add(
Permission(
type = PermissionType.EXTERNAL_STORAGE,
title = context.getString(R.string.onboarding_permission_esa),
subtitle = context.getString(R.string.onboarding_permission_esa_desc),
optional = false,
isGranted = isGranted(context, PermissionType.EXTERNAL_STORAGE)
)
)
}
if (isTAndAbove) {
list.add(
Permission(
type = PermissionType.POST_NOTIFICATIONS,
title = context.getString(R.string.onboarding_permission_notifications),
subtitle = context.getString(R.string.onboarding_permission_notifications_desc),
optional = true,
isGranted = isGranted(context, PermissionType.POST_NOTIFICATIONS)
)
)
}
if (isSAndAbove) {
list.add(
Permission(
type = PermissionType.APP_LINKS,
title = context.getString(R.string.app_links_title),
subtitle = context.getString(R.string.app_links_desc),
optional = true,
isGranted = isGranted(context, PermissionType.APP_LINKS)
),
)
}
return list
}
fun refreshPermissionsList() {
_permissions.value = _permissions.value.map { permission ->
permission.copy(isGranted = isGranted(context, permission.type))
}
}
}