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.
This commit is contained in:
Rahul Patel
2026-05-20 03:42:17 +05:30
parent 572387d7b5
commit 0493075783
11 changed files with 749 additions and 48 deletions

View File

@@ -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

View File

@@ -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"
)
}

View File

@@ -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<BlacklistAppItem>? = null,
sortFilterState: SortFilterState = SortFilterState(),
installers: Map<String, String> = 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)

View File

@@ -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<AppType> = 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 <reified T : Enum<T>> enumValueOrDefault(name: String, default: T): T =
runCatching { enumValueOf<T>(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 <T : InstalledAppMeta> List<T>.applyFilter(state: SortFilterState): List<T> {
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 <T : InstalledAppMeta> List<T>.applySort(state: SortFilterState): List<T> {
val comparator: Comparator<T> = 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()
)
}

View File

@@ -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<String, String>,
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<String, String>,
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 = {}
)
}

View File

@@ -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<App> = emptyPagingItems(),
state: SortFilterState = SortFilterState(),
installers: Map<String, String> = emptyMap(),
metadata: Map<String, InstalledAppMeta> = 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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<App>>(PagingData.empty())
val apps = _apps.asStateFlow()
private val _state = MutableStateFlow(loadSortFilterState(context, PREF_KEYS))
val state = _state.asStateFlow()
private val _installers = MutableStateFlow<Map<String, String>>(emptyMap())
val installers = _installers.asStateFlow()
private val _metadata = MutableStateFlow<Map<String, InstalledAppMeta>>(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()
}
private fun 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 ->
// page is 1-indexed, but list is 0-indexed
val chunks = pagedPackages.await()
val chunk = chunks.getOrNull(page - 1)
?: return@manualPager PageResult(emptyList<App>(), hasMore = false)
val items = webAppDetailsHelper.getAppDetails(chunk.map { it.packageName })
PageResult(items, hasMore = page < chunks.size)
}.flow.distinctUntilChanged()
}.flow
.distinctUntilChanged()
.cachedIn(viewModelScope)
.onEach { _apps.value = it }
.launchIn(viewModelScope)
.collect { _apps.value = it }
}
}
}
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
)
}
}

View File

@@ -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<List<BlacklistAppItem>?>(null)
private val _filteredPackages = MutableStateFlow<List<BlacklistAppItem>?>(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<Map<String, String>>(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<String>()
@@ -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
)
}
}

View File

@@ -562,6 +562,23 @@
<!-- AppsGamesFragment -->
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>
<!-- InstalledScreen sort / filter / group sheet -->
<string name="installed_sort_filter">Sort and filter</string>
<string name="installed_sort_by">Sort by</string>
<string name="installed_sort_name">Name</string>
<string name="installed_sort_date_installed">Date installed</string>
<string name="installed_sort_date_updated">Date updated</string>
<string name="installed_sort_package_name">Package name</string>
<string name="installed_sort_size">Size</string>
<string name="installed_sort_ascending">Ascending</string>
<string name="installed_sort_descending">Descending</string>
<string name="installed_filter">Filter</string>
<string name="installed_filter_user_apps">User apps</string>
<string name="installed_filter_system_apps">System apps</string>
<string name="installed_filter_installer">Installed from</string>
<string name="installed_filter_all">All</string>
<string name="installed_updated_at">Updated <xliff:g id="when">%1$s</xliff:g></string>
<!-- NetworkPreference -->
<string name="pref_network_microg_login_title">Use microG to sign in to accounts</string>
<string name="pref_network_microg_login_desc">Authenticate using microG when signing into Google accounts for a simpler sign-in flow</string>