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.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.res.dimensionResource
|
import androidx.compose.ui.res.dimensionResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -48,7 +50,8 @@ fun BlackListItem(
|
|||||||
Image(
|
Image(
|
||||||
bitmap = icon.asImageBitmap(),
|
bitmap = icon.asImageBitmap(),
|
||||||
contentDescription = null,
|
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 = {
|
trailing = {
|
||||||
@@ -57,6 +60,8 @@ fun BlackListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val DESATURATE = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
|
||||||
|
|
||||||
@PreviewWrapper(ThemePreviewProvider::class)
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.preview.ThemePreviewProvider
|
||||||
import com.aurora.store.compose.ui.blacklist.menu.BlacklistMenu
|
import com.aurora.store.compose.ui.blacklist.menu.BlacklistMenu
|
||||||
import com.aurora.store.compose.ui.blacklist.menu.MenuItem
|
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.data.model.BlacklistAppItem
|
||||||
import com.aurora.store.viewmodel.blacklist.BlacklistViewModel
|
import com.aurora.store.viewmodel.blacklist.BlacklistViewModel
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
@@ -76,9 +80,13 @@ import kotlinx.coroutines.launch
|
|||||||
fun BlacklistScreen(viewModel: BlacklistViewModel = hiltViewModel()) {
|
fun BlacklistScreen(viewModel: BlacklistViewModel = hiltViewModel()) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val packages by viewModel.filteredPackages.collectAsStateWithLifecycle()
|
val packages by viewModel.filteredPackages.collectAsStateWithLifecycle()
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val installers by viewModel.installers.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ScreenContent(
|
ScreenContent(
|
||||||
packages = packages,
|
packages = packages,
|
||||||
|
sortFilterState = state,
|
||||||
|
installers = installers,
|
||||||
isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist },
|
isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist },
|
||||||
onBlacklistImport = { uri ->
|
onBlacklistImport = { uri ->
|
||||||
viewModel.importBlacklist(uri)
|
viewModel.importBlacklist(uri)
|
||||||
@@ -92,13 +100,16 @@ fun BlacklistScreen(viewModel: BlacklistViewModel = hiltViewModel()) {
|
|||||||
onBlacklistAll = { viewModel.blacklistAll() },
|
onBlacklistAll = { viewModel.blacklistAll() },
|
||||||
onWhitelist = { packageName -> viewModel.whitelist(packageName) },
|
onWhitelist = { packageName -> viewModel.whitelist(packageName) },
|
||||||
onWhitelistAll = { viewModel.whitelistAll() },
|
onWhitelistAll = { viewModel.whitelistAll() },
|
||||||
onSearch = { query -> viewModel.search(query) }
|
onSearch = { query -> viewModel.search(query) },
|
||||||
|
onSortFilterStateChange = viewModel::updateState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScreenContent(
|
private fun ScreenContent(
|
||||||
packages: List<BlacklistAppItem>? = null,
|
packages: List<BlacklistAppItem>? = null,
|
||||||
|
sortFilterState: SortFilterState = SortFilterState(),
|
||||||
|
installers: Map<String, String> = emptyMap(),
|
||||||
isPackageBlacklisted: (packageName: String) -> Boolean = { false },
|
isPackageBlacklisted: (packageName: String) -> Boolean = { false },
|
||||||
onBlacklistImport: (uri: Uri) -> Unit = {},
|
onBlacklistImport: (uri: Uri) -> Unit = {},
|
||||||
onBlacklistExport: (uri: Uri) -> Unit = {},
|
onBlacklistExport: (uri: Uri) -> Unit = {},
|
||||||
@@ -106,7 +117,8 @@ private fun ScreenContent(
|
|||||||
onBlacklistAll: () -> Unit = {},
|
onBlacklistAll: () -> Unit = {},
|
||||||
onWhitelist: (packageName: String) -> Unit = {},
|
onWhitelist: (packageName: String) -> Unit = {},
|
||||||
onWhitelistAll: () -> Unit = {},
|
onWhitelistAll: () -> Unit = {},
|
||||||
onSearch: (query: String) -> Unit = {}
|
onSearch: (query: String) -> Unit = {},
|
||||||
|
onSortFilterStateChange: (SortFilterState) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val activity = LocalActivity.current as? ComponentActivity
|
val activity = LocalActivity.current as? ComponentActivity
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -114,6 +126,7 @@ private fun ScreenContent(
|
|||||||
val searchBarState = rememberSearchBarState()
|
val searchBarState = rememberSearchBarState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
var sheetVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val docImportLauncher = rememberLauncherForActivityResult(
|
val docImportLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
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(
|
colors = SearchBarDefaults.appBarWithSearchColors(
|
||||||
appBarContainerColor = Color.Transparent
|
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)
|
@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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.compose.LazyPagingItems
|
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.Placeholder
|
||||||
import com.aurora.store.compose.composable.ScrollHint
|
import com.aurora.store.compose.composable.ScrollHint
|
||||||
import com.aurora.store.compose.composable.TopAppBar
|
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.navigation.Destination
|
||||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
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 com.aurora.store.viewmodel.all.InstalledViewModel
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -54,9 +61,16 @@ fun InstalledScreen(
|
|||||||
viewModel: InstalledViewModel = hiltViewModel()
|
viewModel: InstalledViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val apps = viewModel.apps.collectAsLazyPagingItems()
|
val apps = viewModel.apps.collectAsLazyPagingItems()
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val installers by viewModel.installers.collectAsStateWithLifecycle()
|
||||||
|
val metadata by viewModel.metadata.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ScreenContent(
|
ScreenContent(
|
||||||
apps = apps,
|
apps = apps,
|
||||||
|
state = state,
|
||||||
|
installers = installers,
|
||||||
|
metadata = metadata,
|
||||||
|
onStateChange = viewModel::updateState,
|
||||||
onNavigateTo = onNavigateTo
|
onNavigateTo = onNavigateTo
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -64,6 +78,10 @@ fun InstalledScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ScreenContent(
|
private fun ScreenContent(
|
||||||
apps: LazyPagingItems<App> = emptyPagingItems(),
|
apps: LazyPagingItems<App> = emptyPagingItems(),
|
||||||
|
state: SortFilterState = SortFilterState(),
|
||||||
|
installers: Map<String, String> = emptyMap(),
|
||||||
|
metadata: Map<String, InstalledAppMeta> = emptyMap(),
|
||||||
|
onStateChange: (SortFilterState) -> Unit = {},
|
||||||
onNavigateTo: (Destination) -> 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.
|
* Save the initial loading state to make sure we don't replay the loading animation again.
|
||||||
*/
|
*/
|
||||||
var initialLoad by rememberSaveable { mutableStateOf(true) }
|
var initialLoad by rememberSaveable { mutableStateOf(true) }
|
||||||
|
var sheetVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 ->
|
) { paddingValues ->
|
||||||
@@ -114,8 +141,13 @@ private fun ScreenContent(
|
|||||||
key = apps.itemKey { it.packageName }
|
key = apps.itemKey { it.packageName }
|
||||||
) { index ->
|
) { index ->
|
||||||
apps[index]?.let { app ->
|
apps[index]?.let { app ->
|
||||||
LargeAppListItem(
|
val meta = metadata[app.packageName]
|
||||||
|
InstalledAppListItem(
|
||||||
app = app,
|
app = app,
|
||||||
|
sizeBytes = meta?.sizeBytes ?: 0L,
|
||||||
|
lastUpdateTime = meta?.lastUpdateTime ?: 0L,
|
||||||
|
installerLabel = meta?.installer
|
||||||
|
?.let { installers[it] },
|
||||||
onClick = {
|
onClick = {
|
||||||
onNavigateTo(
|
onNavigateTo(
|
||||||
Destination.AppDetails(app.packageName)
|
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)
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
|||||||
@@ -6,12 +6,20 @@
|
|||||||
package com.aurora.store.data.model
|
package com.aurora.store.data.model
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import com.aurora.store.compose.ui.commons.InstalledAppMeta
|
||||||
|
|
||||||
data class BlacklistAppItem(
|
data class BlacklistAppItem(
|
||||||
val packageName: String,
|
override val packageName: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val versionName: String,
|
val versionName: String,
|
||||||
val versionCode: Long,
|
val versionCode: Long,
|
||||||
val icon: Bitmap,
|
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_IDLE = "PREFERENCES_UPDATES_RESTRICTIONS_IDLE"
|
||||||
const val PREFERENCES_UPDATES_RESTRICTIONS_BATTERY = "PREFERENCES_UPDATES_RESTRICTIONS_BATTERY"
|
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"
|
const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION"
|
||||||
|
|
||||||
private var prefs: SharedPreferences? = null
|
private var prefs: SharedPreferences? = null
|
||||||
|
|||||||
@@ -20,28 +20,41 @@
|
|||||||
package com.aurora.store.viewmodel.all
|
package com.aurora.store.viewmodel.all
|
||||||
|
|
||||||
import android.content.Context
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
|
import com.aurora.extensions.isValidApp
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
import com.aurora.gplayapi.helpers.web.WebAppDetailsHelper
|
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.PageResult
|
||||||
import com.aurora.store.data.paging.GenericPagingSource
|
import com.aurora.store.data.paging.GenericPagingSource
|
||||||
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
|
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
|
||||||
import com.aurora.store.data.providers.BlacklistProvider
|
import com.aurora.store.data.providers.BlacklistProvider
|
||||||
import com.aurora.store.util.PackageUtil
|
import com.aurora.store.util.PackageUtil
|
||||||
|
import com.aurora.store.util.Preferences
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class InstalledViewModel @Inject constructor(
|
class InstalledViewModel @Inject constructor(
|
||||||
@@ -55,35 +68,100 @@ class InstalledViewModel @Inject constructor(
|
|||||||
private val _apps = MutableStateFlow<PagingData<App>>(PagingData.empty())
|
private val _apps = MutableStateFlow<PagingData<App>>(PagingData.empty())
|
||||||
val apps = _apps.asStateFlow()
|
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
|
* Enumerates installed packages and resolves per-package metadata used for sorting
|
||||||
* per app for sorting. Done lazily on [Dispatchers.IO] so the VM constructor and
|
* and filtering. Done lazily on [Dispatchers.IO] so the VM constructor stays cheap;
|
||||||
* the main thread stay snappy; paging awaits this before fetching the first page.
|
* subsequent state changes reuse this cached list.
|
||||||
*/
|
*/
|
||||||
private val pagedPackages = viewModelScope.async(
|
private val enrichedPackages = viewModelScope.async(
|
||||||
context = Dispatchers.IO,
|
context = Dispatchers.IO,
|
||||||
start = CoroutineStart.LAZY
|
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 }
|
.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 {
|
init {
|
||||||
fetchApps()
|
viewModelScope.launch {
|
||||||
}
|
_state.collectLatest { state ->
|
||||||
|
val all = enrichedPackages.await()
|
||||||
private fun fetchApps() {
|
val filteredSorted = all.applyFilter(state).applySort(state)
|
||||||
|
val chunks = filteredSorted.chunked(GenericPagingSource.DEFAULT_PAGE_SIZE)
|
||||||
manualPager(pageSize = GenericPagingSource.DEFAULT_PAGE_SIZE) { page ->
|
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)
|
val chunk = chunks.getOrNull(page - 1)
|
||||||
?: return@manualPager PageResult(emptyList<App>(), hasMore = false)
|
?: return@manualPager PageResult(emptyList<App>(), hasMore = false)
|
||||||
val items = webAppDetailsHelper.getAppDetails(chunk.map { it.packageName })
|
val items = webAppDetailsHelper.getAppDetails(chunk.map { it.packageName })
|
||||||
PageResult(items, hasMore = page < chunks.size)
|
PageResult(items, hasMore = page < chunks.size)
|
||||||
}.flow.distinctUntilChanged()
|
}.flow
|
||||||
|
.distinctUntilChanged()
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
.onEach { _apps.value = it }
|
.collect { _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
|
package com.aurora.store.viewmodel.blacklist
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -16,6 +17,12 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.store.AuroraApp
|
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.event.BusEvent
|
||||||
import com.aurora.store.data.helper.UpdateHelper
|
import com.aurora.store.data.helper.UpdateHelper
|
||||||
import com.aurora.store.data.model.BlacklistAppItem
|
import com.aurora.store.data.model.BlacklistAppItem
|
||||||
@@ -25,10 +32,14 @@ import com.aurora.store.util.PackageUtil
|
|||||||
import com.aurora.store.util.Preferences
|
import com.aurora.store.util.Preferences
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -48,8 +59,26 @@ class BlacklistViewModel @Inject constructor(
|
|||||||
Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED)
|
Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED)
|
||||||
|
|
||||||
private val packages = MutableStateFlow<List<BlacklistAppItem>?>(null)
|
private val packages = MutableStateFlow<List<BlacklistAppItem>?>(null)
|
||||||
private val _filteredPackages = MutableStateFlow<List<BlacklistAppItem>?>(null)
|
private val searchQuery = MutableStateFlow("")
|
||||||
val filteredPackages = _filteredPackages.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()
|
||||||
|
|
||||||
|
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>()
|
val blacklist = mutableStateListOf<String>()
|
||||||
|
|
||||||
@@ -61,9 +90,12 @@ class BlacklistViewModel @Inject constructor(
|
|||||||
private fun fetchApps() {
|
private fun fetchApps() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
packages.value = PackageUtil.getAllValidPackages(context)
|
val items = PackageUtil.getAllValidPackages(context)
|
||||||
.mapNotNull { it.toBlacklistAppItem() }
|
.mapNotNull { it.toBlacklistAppItem() }
|
||||||
.also { _filteredPackages.value = it }
|
packages.value = items
|
||||||
|
_installers.value = items.mapNotNull { it.installer }
|
||||||
|
.toSet()
|
||||||
|
.associateWith { resolveInstallerLabel(it) }
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to fetch apps", exception)
|
Log.e(TAG, "Failed to fetch apps", exception)
|
||||||
}
|
}
|
||||||
@@ -72,28 +104,37 @@ class BlacklistViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun PackageInfo.toBlacklistAppItem(): BlacklistAppItem? {
|
private fun PackageInfo.toBlacklistAppItem(): BlacklistAppItem? {
|
||||||
val icon = PackageUtil.getIconForPackage(context, packageName) ?: return null
|
val icon = PackageUtil.getIconForPackage(context, packageName) ?: return null
|
||||||
|
val appInfo = applicationInfo!!
|
||||||
return BlacklistAppItem(
|
return BlacklistAppItem(
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
displayName = applicationInfo!!.loadLabel(context.packageManager).toString(),
|
displayName = appInfo.loadLabel(context.packageManager).toString(),
|
||||||
versionName = versionName ?: "",
|
versionName = versionName ?: "",
|
||||||
versionCode = PackageInfoCompat.getLongVersionCode(this),
|
versionCode = PackageInfoCompat.getLongVersionCode(this),
|
||||||
icon = icon,
|
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) {
|
fun search(query: String) {
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
searchQuery.value = query
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,6 +562,23 @@
|
|||||||
<!-- AppsGamesFragment -->
|
<!-- AppsGamesFragment -->
|
||||||
<string name="installed_apps_size"><xliff:g id="installed_apps_size">%1$d</xliff:g> apps installed</string>
|
<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 -->
|
<!-- NetworkPreference -->
|
||||||
<string name="pref_network_microg_login_title">Use microG to sign in to accounts</string>
|
<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>
|
<string name="pref_network_microg_login_desc">Authenticate using microG when signing into Google accounts for a simpler sign-in flow</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user