From 0493075783aa812bb21a5eb54ff39da6d99e3a22 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 20 May 2026 03:42:17 +0530 Subject: [PATCH] installed/blacklist: add sort & filter sheet + dedicated list item [1/2] Share a sort/filter sheet between InstalledScreen and BlacklistScreen via new commons module (SortFilterState + SortFilterSheet + applyFilter/applySort on InstalledAppMeta), persisted per-screen. InstalledScreen renders a new InstalledAppListItem that surfaces size, last-update, and installer instead of Play-listing extras. Blacklisted rows now desaturate their icons. --- .../store/compose/composable/BlackListItem.kt | 7 +- .../composable/app/InstalledAppListItem.kt | 100 ++++++++ .../compose/ui/blacklist/BlacklistScreen.kt | 36 ++- .../store/compose/ui/commons/SortFilter.kt | 139 +++++++++++ .../compose/ui/commons/SortFilterSheet.kt | 223 ++++++++++++++++++ .../compose/ui/installed/InstalledScreen.kt | 47 +++- .../store/data/model/BlacklistAppItem.kt | 14 +- .../java/com/aurora/store/util/Preferences.kt | 10 + .../store/viewmodel/all/InstalledViewModel.kt | 120 ++++++++-- .../viewmodel/blacklist/BlacklistViewModel.kt | 84 +++++-- app/src/main/res/values/strings.xml | 17 ++ 11 files changed, 749 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt diff --git a/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt index 7e5f57f1c..e8a8cfc4c 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt @@ -13,6 +13,8 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource @@ -48,7 +50,8 @@ fun BlackListItem( Image( bitmap = icon.asImageBitmap(), contentDescription = null, - modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)) + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), + colorFilter = if (isChecked) DESATURATE else null ) }, trailing = { @@ -57,6 +60,8 @@ fun BlackListItem( ) } +private val DESATURATE = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt new file mode 100644 index 000000000..1def9700b --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable.app + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +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.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewWrapper +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R +import com.aurora.store.compose.composable.AuroraListItem +import com.aurora.store.compose.preview.AppPreviewProvider +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.util.CommonUtil + +/** + * List item for the installed-apps screen. Mirrors [LargeAppListItem] but the tertiary + * line shows installation-relevant info (APK size, last update, installer) rather than + * download / rating / price / ads which only matter on Play listings. + * + * Each tertiary segment is omitted if the corresponding value is missing, so the row + * gracefully collapses when only some metadata is available. + */ +@Composable +fun InstalledAppListItem( + modifier: Modifier = Modifier, + app: App, + sizeBytes: Long = 0L, + lastUpdateTime: Long = 0L, + installerLabel: String? = null, + onClick: () -> Unit = {} +) { + AuroraListItem( + modifier = modifier, + headline = app.displayName, + supporting = app.packageName, + tertiary = buildInstalledExtras(sizeBytes, lastUpdateTime, installerLabel), + headlineStyle = MaterialTheme.typography.bodyMedium, + onClick = onClick, + leading = { + AsyncImage( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))), + model = ImageRequest.Builder(LocalContext.current) + .data(app.iconArtwork.url) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + ) +} + +@Composable +private fun buildInstalledExtras( + sizeBytes: Long, + lastUpdateTime: Long, + installerLabel: String? +): String = buildList { + if (sizeBytes > 0) add(CommonUtil.addSiPrefix(sizeBytes)) + if (lastUpdateTime > 0) { + val relative = DateUtils.getRelativeTimeSpanString( + lastUpdateTime, + System.currentTimeMillis(), + DateUtils.DAY_IN_MILLIS + ) + add(stringResource(R.string.installed_updated_at, relative)) + } + if (!installerLabel.isNullOrBlank()) add(installerLabel) +}.joinToString(separator = " • ") + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun InstalledAppListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + InstalledAppListItem( + app = app, + sizeBytes = 54_300_000L, + lastUpdateTime = System.currentTimeMillis() - DateUtils.DAY_IN_MILLIS * 5, + installerLabel = "Aurora Store" + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt index ada1710ea..e02503127 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt @@ -38,8 +38,10 @@ import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +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.Alignment import androidx.compose.ui.Modifier @@ -65,6 +67,8 @@ import com.aurora.store.compose.composable.TextDividerComposable import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.ui.blacklist.menu.BlacklistMenu import com.aurora.store.compose.ui.blacklist.menu.MenuItem +import com.aurora.store.compose.ui.commons.SortFilterSheet +import com.aurora.store.compose.ui.commons.SortFilterState import com.aurora.store.data.model.BlacklistAppItem import com.aurora.store.viewmodel.blacklist.BlacklistViewModel import java.util.Calendar @@ -76,9 +80,13 @@ import kotlinx.coroutines.launch fun BlacklistScreen(viewModel: BlacklistViewModel = hiltViewModel()) { val context = LocalContext.current val packages by viewModel.filteredPackages.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val installers by viewModel.installers.collectAsStateWithLifecycle() ScreenContent( packages = packages, + sortFilterState = state, + installers = installers, isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist }, onBlacklistImport = { uri -> viewModel.importBlacklist(uri) @@ -92,13 +100,16 @@ fun BlacklistScreen(viewModel: BlacklistViewModel = hiltViewModel()) { onBlacklistAll = { viewModel.blacklistAll() }, onWhitelist = { packageName -> viewModel.whitelist(packageName) }, onWhitelistAll = { viewModel.whitelistAll() }, - onSearch = { query -> viewModel.search(query) } + onSearch = { query -> viewModel.search(query) }, + onSortFilterStateChange = viewModel::updateState ) } @Composable private fun ScreenContent( packages: List? = null, + sortFilterState: SortFilterState = SortFilterState(), + installers: Map = emptyMap(), isPackageBlacklisted: (packageName: String) -> Boolean = { false }, onBlacklistImport: (uri: Uri) -> Unit = {}, onBlacklistExport: (uri: Uri) -> Unit = {}, @@ -106,7 +117,8 @@ private fun ScreenContent( onBlacklistAll: () -> Unit = {}, onWhitelist: (packageName: String) -> Unit = {}, onWhitelistAll: () -> Unit = {}, - onSearch: (query: String) -> Unit = {} + onSearch: (query: String) -> Unit = {}, + onSortFilterStateChange: (SortFilterState) -> Unit = {} ) { val activity = LocalActivity.current as? ComponentActivity val context = LocalContext.current @@ -114,6 +126,7 @@ private fun ScreenContent( val searchBarState = rememberSearchBarState() val focusRequester = remember { FocusRequester() } val coroutineScope = rememberCoroutineScope() + var sheetVisible by remember { mutableStateOf(false) } val docImportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -231,7 +244,15 @@ private fun ScreenContent( ) } }, - actions = { SetupMenu() }, + actions = { + IconButton(onClick = { sheetVisible = true }) { + Icon( + painter = painterResource(R.drawable.ic_tune), + contentDescription = stringResource(R.string.installed_sort_filter) + ) + } + SetupMenu() + }, colors = SearchBarDefaults.appBarWithSearchColors( appBarContainerColor = Color.Transparent ) @@ -338,6 +359,15 @@ private fun ScreenContent( } } } + + if (sheetVisible) { + SortFilterSheet( + state = sortFilterState, + installers = installers, + onStateChange = onSortFilterStateChange, + onDismiss = { sheetVisible = false } + ) + } } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt new file mode 100644 index 000000000..bf6c8def1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import android.content.Context +import com.aurora.store.R +import com.aurora.store.util.Preferences +import java.util.Locale + +/** + * User-controlled axis for ordering a list of installed apps. + */ +enum class SortBy { + NAME, + DATE_INSTALLED, + DATE_UPDATED, + PACKAGE_NAME, + SIZE +} + +enum class SortOrder { ASC, DESC } + +enum class AppType { USER, SYSTEM } + +/** + * Sort + filter state shared by screens that list installed apps. Persisted via + * [Preferences] using a per-screen [SortFilterPrefKeys] so each screen restores + * its own last selection. + * + * [installer] is the package name of the installing source to filter by, or null + * for "all installers". [appTypes] is a non-empty subset; the UI guards against + * the empty case by always keeping at least one type selected. + */ +data class SortFilterState( + val sortBy: SortBy = SortBy.NAME, + val sortOrder: SortOrder = SortOrder.ASC, + val appTypes: Set = setOf(AppType.USER), + val installer: String? = null +) + +/** + * SharedPreferences keys a screen uses to persist its [SortFilterState]. + */ +data class SortFilterPrefKeys( + val sortBy: String, + val sortOrder: String, + val appTypes: String, + val installer: String +) + +fun SortFilterState.save(context: Context, keys: SortFilterPrefKeys) { + Preferences.putString(context, keys.sortBy, sortBy.name) + Preferences.putString(context, keys.sortOrder, sortOrder.name) + Preferences.putStringSet(context, keys.appTypes, appTypes.map { it.name }.toSet()) + if (installer == null) { + Preferences.remove(context, keys.installer) + } else { + Preferences.putString(context, keys.installer, installer) + } +} + +fun loadSortFilterState(context: Context, keys: SortFilterPrefKeys): SortFilterState { + val sortBy = enumValueOrDefault(Preferences.getString(context, keys.sortBy), SortBy.NAME) + val sortOrder = enumValueOrDefault( + Preferences.getString(context, keys.sortOrder), + SortOrder.ASC + ) + val appTypes = Preferences.getStringSet(context, keys.appTypes) + .mapNotNull { runCatching { AppType.valueOf(it) }.getOrNull() } + .toSet() + .ifEmpty { setOf(AppType.USER) } + val installer = Preferences.getString(context, keys.installer).ifBlank { null } + return SortFilterState(sortBy, sortOrder, appTypes, installer) +} + +private inline fun > enumValueOrDefault(name: String, default: T): T = + runCatching { enumValueOf(name) }.getOrDefault(default) + +fun SortBy.labelRes(): Int = when (this) { + SortBy.NAME -> R.string.installed_sort_name + SortBy.DATE_INSTALLED -> R.string.installed_sort_date_installed + SortBy.DATE_UPDATED -> R.string.installed_sort_date_updated + SortBy.PACKAGE_NAME -> R.string.installed_sort_package_name + SortBy.SIZE -> R.string.installed_sort_size +} + +fun SortOrder.labelRes(): Int = when (this) { + SortOrder.ASC -> R.string.installed_sort_ascending + SortOrder.DESC -> R.string.installed_sort_descending +} + +fun AppType.labelRes(): Int = when (this) { + AppType.USER -> R.string.installed_filter_user_apps + AppType.SYSTEM -> R.string.installed_filter_system_apps +} + +/** + * Per-app metadata that [applyFilter] and [applySort] read. Each consumer screen + * has its own enriched model (e.g. `EnrichedPackage`, `BlacklistAppItem`) that + * implements this interface so the shared filter/sort logic stays type-safe. + */ +interface InstalledAppMeta { + val packageName: String + val label: String + val firstInstallTime: Long + val lastUpdateTime: Long + val sizeBytes: Long + val isSystem: Boolean + val installer: String? +} + +fun List.applyFilter(state: SortFilterState): List { + val typeFilter: (T) -> Boolean = when { + state.appTypes.containsAll(listOf(AppType.USER, AppType.SYSTEM)) -> { _ -> true } + AppType.USER in state.appTypes -> { pkg -> !pkg.isSystem } + AppType.SYSTEM in state.appTypes -> { pkg -> pkg.isSystem } + else -> { _ -> false } + } + val installerFilter: (T) -> Boolean = state.installer + ?.let { wanted -> { pkg: T -> pkg.installer == wanted } } + ?: { _ -> true } + return filter { typeFilter(it) && installerFilter(it) } +} + +fun List.applySort(state: SortFilterState): List { + val comparator: Comparator = when (state.sortBy) { + SortBy.NAME -> compareBy { it.label.lowercase(Locale.getDefault()) } + SortBy.DATE_INSTALLED -> compareBy { it.firstInstallTime } + SortBy.DATE_UPDATED -> compareBy { it.lastUpdateTime } + SortBy.PACKAGE_NAME -> compareBy { it.packageName.lowercase(Locale.getDefault()) } + SortBy.SIZE -> compareBy { it.sizeBytes } + } + return sortedWith( + if (state.sortOrder == SortOrder.ASC) comparator else comparator.reversed() + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt new file mode 100644 index 000000000..7b7144901 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.composable.SectionHeader +import com.aurora.store.compose.preview.ThemePreviewProvider + +/** + * Sheet that lets the user choose how a list of installed apps is sorted and + * filtered. Edits propagate immediately via [onStateChange]; the sheet itself is + * stateless. + * + * @param installers Map of installer package name to human-readable label, used + * to populate the installer filter chips. When empty the installer row is + * hidden. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SortFilterSheet( + state: SortFilterState, + installers: Map, + onStateChange: (SortFilterState) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + SortSection(state = state, onStateChange = onStateChange) + HorizontalDivider( + modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall)) + ) + FilterSection( + state = state, + installers = installers, + onStateChange = onStateChange + ) + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SortSection(state: SortFilterState, onStateChange: (SortFilterState) -> Unit) { + SectionHeader(title = stringResource(R.string.installed_sort_by)) + SortBy.entries.forEach { option -> + SelectableRow( + label = stringResource(option.labelRes()), + selected = state.sortBy == option, + onClick = { onStateChange(state.copy(sortBy = option)) } + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SortOrder.entries.forEachIndexed { index, order -> + SegmentedButton( + selected = state.sortOrder == order, + onClick = { onStateChange(state.copy(sortOrder = order)) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = SortOrder.entries.size + ) + ) { + Text(text = stringResource(order.labelRes())) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FilterSection( + state: SortFilterState, + installers: Map, + onStateChange: (SortFilterState) -> Unit +) { + SectionHeader(title = stringResource(R.string.installed_filter)) + AppType.entries.forEach { type -> + CheckableRow( + label = stringResource(type.labelRes()), + checked = type in state.appTypes, + onCheckedChange = { checked -> + val next = if (checked) state.appTypes + type else state.appTypes - type + // Always keep at least one type selected to avoid an empty list. + if (next.isNotEmpty()) onStateChange(state.copy(appTypes = next)) + } + ) + } + if (installers.isNotEmpty()) { + Text( + text = stringResource(R.string.installed_filter_installer), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) + ) { + FilterChip( + selected = state.installer == null, + onClick = { onStateChange(state.copy(installer = null)) }, + label = { Text(text = stringResource(R.string.installed_filter_all)) } + ) + installers.entries.sortedBy { it.value.lowercase() }.forEach { (pkg, label) -> + FilterChip( + selected = state.installer == pkg, + onClick = { + onStateChange( + state.copy(installer = if (state.installer == pkg) null else pkg) + ) + }, + label = { Text(text = label) } + ) + } + } + } +} + +@Composable +private fun SelectableRow(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + RadioButton(selected = selected, onClick = onClick) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun CheckableRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun SortFilterSheetPreview() { + SortFilterSheet( + state = SortFilterState(), + installers = mapOf( + "com.android.vending" to "Google Play Store", + "com.aurora.store" to "Aurora Store", + "org.fdroid.fdroid" to "F-Droid" + ), + onStateChange = {}, + onDismiss = {} + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt index 6d26e4da1..86740e28d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt @@ -13,10 +13,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -28,6 +31,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -40,10 +44,13 @@ import com.aurora.store.compose.composable.ContainedLoadingIndicator import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.TopAppBar -import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.composable.app.InstalledAppListItem import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.commons.InstalledAppMeta +import com.aurora.store.compose.ui.commons.SortFilterSheet +import com.aurora.store.compose.ui.commons.SortFilterState import com.aurora.store.viewmodel.all.InstalledViewModel import kotlin.random.Random import kotlinx.coroutines.flow.MutableStateFlow @@ -54,9 +61,16 @@ fun InstalledScreen( viewModel: InstalledViewModel = hiltViewModel() ) { val apps = viewModel.apps.collectAsLazyPagingItems() + val state by viewModel.state.collectAsStateWithLifecycle() + val installers by viewModel.installers.collectAsStateWithLifecycle() + val metadata by viewModel.metadata.collectAsStateWithLifecycle() ScreenContent( apps = apps, + state = state, + installers = installers, + metadata = metadata, + onStateChange = viewModel::updateState, onNavigateTo = onNavigateTo ) } @@ -64,6 +78,10 @@ fun InstalledScreen( @Composable private fun ScreenContent( apps: LazyPagingItems = emptyPagingItems(), + state: SortFilterState = SortFilterState(), + installers: Map = emptyMap(), + metadata: Map = emptyMap(), + onStateChange: (SortFilterState) -> Unit = {}, onNavigateTo: (Destination) -> Unit = {} ) { /* @@ -73,11 +91,20 @@ private fun ScreenContent( * Save the initial loading state to make sure we don't replay the loading animation again. */ var initialLoad by rememberSaveable { mutableStateOf(true) } + var sheetVisible by remember { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( - title = stringResource(R.string.title_apps_games) + title = stringResource(R.string.title_apps_games), + actions = { + IconButton(onClick = { sheetVisible = true }) { + Icon( + painter = painterResource(R.drawable.ic_tune), + contentDescription = stringResource(R.string.installed_sort_filter) + ) + } + } ) } ) { paddingValues -> @@ -114,8 +141,13 @@ private fun ScreenContent( key = apps.itemKey { it.packageName } ) { index -> apps[index]?.let { app -> - LargeAppListItem( + val meta = metadata[app.packageName] + InstalledAppListItem( app = app, + sizeBytes = meta?.sizeBytes ?: 0L, + lastUpdateTime = meta?.lastUpdateTime ?: 0L, + installerLabel = meta?.installer + ?.let { installers[it] }, onClick = { onNavigateTo( Destination.AppDetails(app.packageName) @@ -135,6 +167,15 @@ private fun ScreenContent( } } } + + if (sheetVisible) { + SortFilterSheet( + state = state, + installers = installers, + onStateChange = onStateChange, + onDismiss = { sheetVisible = false } + ) + } } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt b/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt index ed31f2877..a57a1e051 100644 --- a/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt +++ b/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt @@ -6,12 +6,20 @@ package com.aurora.store.data.model import android.graphics.Bitmap +import com.aurora.store.compose.ui.commons.InstalledAppMeta data class BlacklistAppItem( - val packageName: String, + override val packageName: String, val displayName: String, val versionName: String, val versionCode: Long, val icon: Bitmap, - val isFiltered: Boolean -) + val isFiltered: Boolean, + override val firstInstallTime: Long = 0L, + override val lastUpdateTime: Long = 0L, + override val sizeBytes: Long = 0L, + override val isSystem: Boolean = false, + override val installer: String? = null +) : InstalledAppMeta { + override val label: String get() = displayName +} diff --git a/app/src/main/java/com/aurora/store/util/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index dc56d21ca..5af10d467 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -62,6 +62,16 @@ object Preferences { const val PREFERENCES_UPDATES_RESTRICTIONS_IDLE = "PREFERENCES_UPDATES_RESTRICTIONS_IDLE" const val PREFERENCES_UPDATES_RESTRICTIONS_BATTERY = "PREFERENCES_UPDATES_RESTRICTIONS_BATTERY" + const val PREFERENCE_INSTALLED_SORT_BY = "PREFERENCE_INSTALLED_SORT_BY" + const val PREFERENCE_INSTALLED_SORT_ORDER = "PREFERENCE_INSTALLED_SORT_ORDER" + const val PREFERENCE_INSTALLED_APP_TYPES = "PREFERENCE_INSTALLED_APP_TYPES" + const val PREFERENCE_INSTALLED_INSTALLER = "PREFERENCE_INSTALLED_INSTALLER" + + const val PREFERENCE_BLACKLIST_SORT_BY = "PREFERENCE_BLACKLIST_SORT_BY" + const val PREFERENCE_BLACKLIST_SORT_ORDER = "PREFERENCE_BLACKLIST_SORT_ORDER" + const val PREFERENCE_BLACKLIST_APP_TYPES = "PREFERENCE_BLACKLIST_APP_TYPES" + const val PREFERENCE_BLACKLIST_INSTALLER = "PREFERENCE_BLACKLIST_INSTALLER" + const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION" private var prefs: SharedPreferences? = null diff --git a/app/src/main/java/com/aurora/store/viewmodel/all/InstalledViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/all/InstalledViewModel.kt index 155396ef9..dd76c50f2 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/all/InstalledViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/all/InstalledViewModel.kt @@ -20,28 +20,41 @@ package com.aurora.store.viewmodel.all import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import com.aurora.extensions.isValidApp import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.helpers.web.WebAppDetailsHelper +import com.aurora.store.compose.ui.commons.InstalledAppMeta +import com.aurora.store.compose.ui.commons.SortFilterPrefKeys +import com.aurora.store.compose.ui.commons.SortFilterState +import com.aurora.store.compose.ui.commons.applyFilter +import com.aurora.store.compose.ui.commons.applySort +import com.aurora.store.compose.ui.commons.loadSortFilterState +import com.aurora.store.compose.ui.commons.save import com.aurora.store.data.PageResult import com.aurora.store.data.paging.GenericPagingSource import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager import com.aurora.store.data.providers.BlacklistProvider import com.aurora.store.util.PackageUtil +import com.aurora.store.util.Preferences import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch @HiltViewModel class InstalledViewModel @Inject constructor( @@ -55,35 +68,100 @@ class InstalledViewModel @Inject constructor( private val _apps = MutableStateFlow>(PagingData.empty()) val apps = _apps.asStateFlow() + private val _state = MutableStateFlow(loadSortFilterState(context, PREF_KEYS)) + val state = _state.asStateFlow() + + private val _installers = MutableStateFlow>(emptyMap()) + val installers = _installers.asStateFlow() + + private val _metadata = MutableStateFlow>(emptyMap()) + val metadata = _metadata.asStateFlow() + /** - * Enumerating installed packages calls into PackageManager and resolves a label - * per app for sorting. Done lazily on [Dispatchers.IO] so the VM constructor and - * the main thread stay snappy; paging awaits this before fetching the first page. + * Enumerates installed packages and resolves per-package metadata used for sorting + * and filtering. Done lazily on [Dispatchers.IO] so the VM constructor stays cheap; + * subsequent state changes reuse this cached list. */ - private val pagedPackages = viewModelScope.async( + private val enrichedPackages = viewModelScope.async( context = Dispatchers.IO, start = CoroutineStart.LAZY ) { - PackageUtil.getAllValidPackages(context) + val pm = context.packageManager + val packages = pm.getInstalledPackages(PackageManager.GET_META_DATA) + .filter { it.isValidApp(pm) } .filterNot { it.packageName in blacklist } - .chunked(GenericPagingSource.DEFAULT_PAGE_SIZE) + .map { info -> + val appInfo = info.applicationInfo!! + EnrichedPackage( + info = info, + label = appInfo.loadLabel(pm).toString(), + installer = PackageUtil.getInstallerPackageName(context, info.packageName), + sizeBytes = runCatching { File(appInfo.sourceDir).length() }.getOrDefault(0L), + isSystem = appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 + ) + } + + // Build installer label map for the filter UI (packageName -> human label). + _installers.value = packages.mapNotNull { it.installer } + .toSet() + .associateWith { resolveInstallerLabel(it) } + + // Per-app metadata lookup for the list item tertiary line. + _metadata.value = packages.associateBy { it.packageName } + + packages } init { - fetchApps() + viewModelScope.launch { + _state.collectLatest { state -> + val all = enrichedPackages.await() + val filteredSorted = all.applyFilter(state).applySort(state) + val chunks = filteredSorted.chunked(GenericPagingSource.DEFAULT_PAGE_SIZE) + manualPager(pageSize = GenericPagingSource.DEFAULT_PAGE_SIZE) { page -> + val chunk = chunks.getOrNull(page - 1) + ?: return@manualPager PageResult(emptyList(), hasMore = false) + val items = webAppDetailsHelper.getAppDetails(chunk.map { it.packageName }) + PageResult(items, hasMore = page < chunks.size) + }.flow + .distinctUntilChanged() + .cachedIn(viewModelScope) + .collect { _apps.value = it } + } + } } - private fun fetchApps() { - manualPager(pageSize = GenericPagingSource.DEFAULT_PAGE_SIZE) { page -> - // page is 1-indexed, but list is 0-indexed - val chunks = pagedPackages.await() - val chunk = chunks.getOrNull(page - 1) - ?: return@manualPager PageResult(emptyList(), hasMore = false) - val items = webAppDetailsHelper.getAppDetails(chunk.map { it.packageName }) - PageResult(items, hasMore = page < chunks.size) - }.flow.distinctUntilChanged() - .cachedIn(viewModelScope) - .onEach { _apps.value = it } - .launchIn(viewModelScope) + fun updateState(newState: SortFilterState) { + if (newState == _state.value) return + _state.value = newState + newState.save(context, PREF_KEYS) + } + + private fun resolveInstallerLabel(packageName: String): String = try { + val info = PackageUtil.getPackageInfo(context, packageName) + info.applicationInfo?.loadLabel(context.packageManager)?.toString() ?: packageName + } catch (_: Exception) { + packageName + } + + private data class EnrichedPackage( + val info: PackageInfo, + override val label: String, + override val installer: String?, + override val sizeBytes: Long, + override val isSystem: Boolean + ) : InstalledAppMeta { + override val packageName: String get() = info.packageName + override val firstInstallTime: Long get() = info.firstInstallTime + override val lastUpdateTime: Long get() = info.lastUpdateTime + } + + companion object { + private val PREF_KEYS = SortFilterPrefKeys( + sortBy = Preferences.PREFERENCE_INSTALLED_SORT_BY, + sortOrder = Preferences.PREFERENCE_INSTALLED_SORT_ORDER, + appTypes = Preferences.PREFERENCE_INSTALLED_APP_TYPES, + installer = Preferences.PREFERENCE_INSTALLED_INSTALLER + ) } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/blacklist/BlacklistViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/blacklist/BlacklistViewModel.kt index e5e694ac6..b5de95420 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/blacklist/BlacklistViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/blacklist/BlacklistViewModel.kt @@ -7,6 +7,7 @@ package com.aurora.store.viewmodel.blacklist import android.content.Context +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.net.Uri import android.util.Log @@ -16,6 +17,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.aurora.extensions.TAG import com.aurora.store.AuroraApp +import com.aurora.store.compose.ui.commons.SortFilterPrefKeys +import com.aurora.store.compose.ui.commons.SortFilterState +import com.aurora.store.compose.ui.commons.applyFilter +import com.aurora.store.compose.ui.commons.applySort +import com.aurora.store.compose.ui.commons.loadSortFilterState +import com.aurora.store.compose.ui.commons.save import com.aurora.store.data.event.BusEvent import com.aurora.store.data.helper.UpdateHelper import com.aurora.store.data.model.BlacklistAppItem @@ -25,10 +32,14 @@ import com.aurora.store.util.PackageUtil import com.aurora.store.util.Preferences import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -48,8 +59,26 @@ class BlacklistViewModel @Inject constructor( Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED) private val packages = MutableStateFlow?>(null) - private val _filteredPackages = MutableStateFlow?>(null) - val filteredPackages = _filteredPackages.asStateFlow() + private val searchQuery = MutableStateFlow("") + + private val _state = MutableStateFlow(loadSortFilterState(context, PREF_KEYS)) + val state = _state.asStateFlow() + + private val _installers = MutableStateFlow>(emptyMap()) + val installers = _installers.asStateFlow() + + val filteredPackages = combine(packages, _state, searchQuery) { all, state, query -> + all?.applyFilter(state)?.applySort(state)?.let { filtered -> + if (query.isBlank()) { + filtered + } else { + filtered.filter { + it.displayName.contains(query, true) || + it.packageName.contains(query, true) + } + } + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) val blacklist = mutableStateListOf() @@ -61,9 +90,12 @@ class BlacklistViewModel @Inject constructor( private fun fetchApps() { viewModelScope.launch(Dispatchers.IO) { try { - packages.value = PackageUtil.getAllValidPackages(context) + val items = PackageUtil.getAllValidPackages(context) .mapNotNull { it.toBlacklistAppItem() } - .also { _filteredPackages.value = it } + packages.value = items + _installers.value = items.mapNotNull { it.installer } + .toSet() + .associateWith { resolveInstallerLabel(it) } } catch (exception: Exception) { Log.e(TAG, "Failed to fetch apps", exception) } @@ -72,28 +104,37 @@ class BlacklistViewModel @Inject constructor( private fun PackageInfo.toBlacklistAppItem(): BlacklistAppItem? { val icon = PackageUtil.getIconForPackage(context, packageName) ?: return null + val appInfo = applicationInfo!! return BlacklistAppItem( packageName = packageName, - displayName = applicationInfo!!.loadLabel(context.packageManager).toString(), + displayName = appInfo.loadLabel(context.packageManager).toString(), versionName = versionName ?: "", versionCode = PackageInfoCompat.getLongVersionCode(this), icon = icon, - isFiltered = isFiltered(this) + isFiltered = isFiltered(this), + firstInstallTime = firstInstallTime, + lastUpdateTime = lastUpdateTime, + sizeBytes = runCatching { File(appInfo.sourceDir).length() }.getOrDefault(0L), + isSystem = appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0, + installer = PackageUtil.getInstallerPackageName(context, packageName) ) } fun search(query: String) { - viewModelScope.launch(Dispatchers.Default) { - val allPackages = packages.value ?: return@launch - if (query.isNotBlank()) { - _filteredPackages.value = allPackages.filter { - it.displayName.contains(query, true) || - it.packageName.contains(query, true) - } - } else { - _filteredPackages.value = allPackages - } - } + searchQuery.value = query + } + + fun updateState(newState: SortFilterState) { + if (newState == _state.value) return + _state.value = newState + newState.save(context, PREF_KEYS) + } + + private fun resolveInstallerLabel(packageName: String): String = try { + val info = PackageUtil.getPackageInfo(context, packageName) + info.applicationInfo?.loadLabel(context.packageManager)?.toString() ?: packageName + } catch (_: Exception) { + packageName } private fun isFiltered(packageInfo: PackageInfo): Boolean = when { @@ -171,4 +212,13 @@ class BlacklistViewModel @Inject constructor( } } } + + companion object { + private val PREF_KEYS = SortFilterPrefKeys( + sortBy = Preferences.PREFERENCE_BLACKLIST_SORT_BY, + sortOrder = Preferences.PREFERENCE_BLACKLIST_SORT_ORDER, + appTypes = Preferences.PREFERENCE_BLACKLIST_APP_TYPES, + installer = Preferences.PREFERENCE_BLACKLIST_INSTALLER + ) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e240d90e9..20536b025 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -562,6 +562,23 @@ %1$d apps installed + + Sort and filter + Sort by + Name + Date installed + Date updated + Package name + Size + Ascending + Descending + Filter + User apps + System apps + Installed from + All + Updated %1$s + Use microG to sign in to accounts Authenticate using microG when signing into Google accounts for a simpler sign-in flow