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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
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<App>(), 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<App>(), 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user