installed/blacklist: add sort & filter sheet + dedicated list item [1/2]

Share a sort/filter sheet between InstalledScreen and BlacklistScreen via
new commons module (SortFilterState + SortFilterSheet + applyFilter/applySort
on InstalledAppMeta), persisted per-screen. InstalledScreen renders a new
InstalledAppListItem that surfaces size, last-update, and installer instead
of Play-listing extras. Blacklisted rows now desaturate their icons.
This commit is contained in:
Rahul Patel
2026-05-20 03:42:17 +05:30
parent 572387d7b5
commit 0493075783
11 changed files with 749 additions and 48 deletions

View File

@@ -13,6 +13,8 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme import androidx.compose.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

View File

@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composable.app
import android.text.format.DateUtils
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewWrapper
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composable.AuroraListItem
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.util.CommonUtil
/**
* List item for the installed-apps screen. Mirrors [LargeAppListItem] but the tertiary
* line shows installation-relevant info (APK size, last update, installer) rather than
* download / rating / price / ads which only matter on Play listings.
*
* Each tertiary segment is omitted if the corresponding value is missing, so the row
* gracefully collapses when only some metadata is available.
*/
@Composable
fun InstalledAppListItem(
modifier: Modifier = Modifier,
app: App,
sizeBytes: Long = 0L,
lastUpdateTime: Long = 0L,
installerLabel: String? = null,
onClick: () -> Unit = {}
) {
AuroraListItem(
modifier = modifier,
headline = app.displayName,
supporting = app.packageName,
tertiary = buildInstalledExtras(sizeBytes, lastUpdateTime, installerLabel),
headlineStyle = MaterialTheme.typography.bodyMedium,
onClick = onClick,
leading = {
AsyncImage(
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))),
model = ImageRequest.Builder(LocalContext.current)
.data(app.iconArtwork.url)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop
)
}
)
}
@Composable
private fun buildInstalledExtras(
sizeBytes: Long,
lastUpdateTime: Long,
installerLabel: String?
): String = buildList {
if (sizeBytes > 0) add(CommonUtil.addSiPrefix(sizeBytes))
if (lastUpdateTime > 0) {
val relative = DateUtils.getRelativeTimeSpanString(
lastUpdateTime,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS
)
add(stringResource(R.string.installed_updated_at, relative))
}
if (!installerLabel.isNullOrBlank()) add(installerLabel)
}.joinToString(separator = "")
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun InstalledAppListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
InstalledAppListItem(
app = app,
sizeBytes = 54_300_000L,
lastUpdateTime = System.currentTimeMillis() - DateUtils.DAY_IN_MILLIS * 5,
installerLabel = "Aurora Store"
)
}

View File

@@ -38,8 +38,10 @@ import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.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)

View File

@@ -0,0 +1,139 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.commons
import android.content.Context
import com.aurora.store.R
import com.aurora.store.util.Preferences
import java.util.Locale
/**
* User-controlled axis for ordering a list of installed apps.
*/
enum class SortBy {
NAME,
DATE_INSTALLED,
DATE_UPDATED,
PACKAGE_NAME,
SIZE
}
enum class SortOrder { ASC, DESC }
enum class AppType { USER, SYSTEM }
/**
* Sort + filter state shared by screens that list installed apps. Persisted via
* [Preferences] using a per-screen [SortFilterPrefKeys] so each screen restores
* its own last selection.
*
* [installer] is the package name of the installing source to filter by, or null
* for "all installers". [appTypes] is a non-empty subset; the UI guards against
* the empty case by always keeping at least one type selected.
*/
data class SortFilterState(
val sortBy: SortBy = SortBy.NAME,
val sortOrder: SortOrder = SortOrder.ASC,
val appTypes: Set<AppType> = setOf(AppType.USER),
val installer: String? = null
)
/**
* SharedPreferences keys a screen uses to persist its [SortFilterState].
*/
data class SortFilterPrefKeys(
val sortBy: String,
val sortOrder: String,
val appTypes: String,
val installer: String
)
fun SortFilterState.save(context: Context, keys: SortFilterPrefKeys) {
Preferences.putString(context, keys.sortBy, sortBy.name)
Preferences.putString(context, keys.sortOrder, sortOrder.name)
Preferences.putStringSet(context, keys.appTypes, appTypes.map { it.name }.toSet())
if (installer == null) {
Preferences.remove(context, keys.installer)
} else {
Preferences.putString(context, keys.installer, installer)
}
}
fun loadSortFilterState(context: Context, keys: SortFilterPrefKeys): SortFilterState {
val sortBy = enumValueOrDefault(Preferences.getString(context, keys.sortBy), SortBy.NAME)
val sortOrder = enumValueOrDefault(
Preferences.getString(context, keys.sortOrder),
SortOrder.ASC
)
val appTypes = Preferences.getStringSet(context, keys.appTypes)
.mapNotNull { runCatching { AppType.valueOf(it) }.getOrNull() }
.toSet()
.ifEmpty { setOf(AppType.USER) }
val installer = Preferences.getString(context, keys.installer).ifBlank { null }
return SortFilterState(sortBy, sortOrder, appTypes, installer)
}
private inline fun <reified T : Enum<T>> enumValueOrDefault(name: String, default: T): T =
runCatching { enumValueOf<T>(name) }.getOrDefault(default)
fun SortBy.labelRes(): Int = when (this) {
SortBy.NAME -> R.string.installed_sort_name
SortBy.DATE_INSTALLED -> R.string.installed_sort_date_installed
SortBy.DATE_UPDATED -> R.string.installed_sort_date_updated
SortBy.PACKAGE_NAME -> R.string.installed_sort_package_name
SortBy.SIZE -> R.string.installed_sort_size
}
fun SortOrder.labelRes(): Int = when (this) {
SortOrder.ASC -> R.string.installed_sort_ascending
SortOrder.DESC -> R.string.installed_sort_descending
}
fun AppType.labelRes(): Int = when (this) {
AppType.USER -> R.string.installed_filter_user_apps
AppType.SYSTEM -> R.string.installed_filter_system_apps
}
/**
* Per-app metadata that [applyFilter] and [applySort] read. Each consumer screen
* has its own enriched model (e.g. `EnrichedPackage`, `BlacklistAppItem`) that
* implements this interface so the shared filter/sort logic stays type-safe.
*/
interface InstalledAppMeta {
val packageName: String
val label: String
val firstInstallTime: Long
val lastUpdateTime: Long
val sizeBytes: Long
val isSystem: Boolean
val installer: String?
}
fun <T : InstalledAppMeta> List<T>.applyFilter(state: SortFilterState): List<T> {
val typeFilter: (T) -> Boolean = when {
state.appTypes.containsAll(listOf(AppType.USER, AppType.SYSTEM)) -> { _ -> true }
AppType.USER in state.appTypes -> { pkg -> !pkg.isSystem }
AppType.SYSTEM in state.appTypes -> { pkg -> pkg.isSystem }
else -> { _ -> false }
}
val installerFilter: (T) -> Boolean = state.installer
?.let { wanted -> { pkg: T -> pkg.installer == wanted } }
?: { _ -> true }
return filter { typeFilter(it) && installerFilter(it) }
}
fun <T : InstalledAppMeta> List<T>.applySort(state: SortFilterState): List<T> {
val comparator: Comparator<T> = when (state.sortBy) {
SortBy.NAME -> compareBy { it.label.lowercase(Locale.getDefault()) }
SortBy.DATE_INSTALLED -> compareBy { it.firstInstallTime }
SortBy.DATE_UPDATED -> compareBy { it.lastUpdateTime }
SortBy.PACKAGE_NAME -> compareBy { it.packageName.lowercase(Locale.getDefault()) }
SortBy.SIZE -> compareBy { it.sizeBytes }
}
return sortedWith(
if (state.sortOrder == SortOrder.ASC) comparator else comparator.reversed()
)
}

View File

@@ -0,0 +1,223 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.commons
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.store.R
import com.aurora.store.compose.composable.SectionHeader
import com.aurora.store.compose.preview.ThemePreviewProvider
/**
* Sheet that lets the user choose how a list of installed apps is sorted and
* filtered. Edits propagate immediately via [onStateChange]; the sheet itself is
* stateless.
*
* @param installers Map of installer package name to human-readable label, used
* to populate the installer filter chips. When empty the installer row is
* hidden.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SortFilterSheet(
state: SortFilterState,
installers: Map<String, String>,
onStateChange: (SortFilterState) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
SortSection(state = state, onStateChange = onStateChange)
HorizontalDivider(
modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall))
)
FilterSection(
state = state,
installers = installers,
onStateChange = onStateChange
)
Spacer(Modifier.navigationBarsPadding())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SortSection(state: SortFilterState, onStateChange: (SortFilterState) -> Unit) {
SectionHeader(title = stringResource(R.string.installed_sort_by))
SortBy.entries.forEach { option ->
SelectableRow(
label = stringResource(option.labelRes()),
selected = state.sortBy == option,
onClick = { onStateChange(state.copy(sortBy = option)) }
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SortOrder.entries.forEachIndexed { index, order ->
SegmentedButton(
selected = state.sortOrder == order,
onClick = { onStateChange(state.copy(sortOrder = order)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = SortOrder.entries.size
)
) {
Text(text = stringResource(order.labelRes()))
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FilterSection(
state: SortFilterState,
installers: Map<String, String>,
onStateChange: (SortFilterState) -> Unit
) {
SectionHeader(title = stringResource(R.string.installed_filter))
AppType.entries.forEach { type ->
CheckableRow(
label = stringResource(type.labelRes()),
checked = type in state.appTypes,
onCheckedChange = { checked ->
val next = if (checked) state.appTypes + type else state.appTypes - type
// Always keep at least one type selected to avoid an empty list.
if (next.isNotEmpty()) onStateChange(state.copy(appTypes = next))
}
)
}
if (installers.isNotEmpty()) {
Text(
text = stringResource(R.string.installed_filter_installer),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.spacing_medium)),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall))
) {
FilterChip(
selected = state.installer == null,
onClick = { onStateChange(state.copy(installer = null)) },
label = { Text(text = stringResource(R.string.installed_filter_all)) }
)
installers.entries.sortedBy { it.value.lowercase() }.forEach { (pkg, label) ->
FilterChip(
selected = state.installer == pkg,
onClick = {
onStateChange(
state.copy(installer = if (state.installer == pkg) null else pkg)
)
},
label = { Text(text = label) }
)
}
}
}
}
@Composable
private fun SelectableRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
) {
RadioButton(selected = selected, onClick = onClick)
Text(text = label, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun CheckableRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small))
) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Text(text = label, style = MaterialTheme.typography.bodyLarge)
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun SortFilterSheetPreview() {
SortFilterSheet(
state = SortFilterState(),
installers = mapOf(
"com.android.vending" to "Google Play Store",
"com.aurora.store" to "Aurora Store",
"org.fdroid.fdroid" to "F-Droid"
),
onStateChange = {},
onDismiss = {}
)
}

View File

@@ -13,10 +13,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.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)

View File

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

View File

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

View File

@@ -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()
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() { fun updateState(newState: SortFilterState) {
manualPager(pageSize = GenericPagingSource.DEFAULT_PAGE_SIZE) { page -> if (newState == _state.value) return
// page is 1-indexed, but list is 0-indexed _state.value = newState
val chunks = pagedPackages.await() newState.save(context, PREF_KEYS)
val chunk = chunks.getOrNull(page - 1) }
?: return@manualPager PageResult(emptyList<App>(), hasMore = false)
val items = webAppDetailsHelper.getAppDetails(chunk.map { it.packageName }) private fun resolveInstallerLabel(packageName: String): String = try {
PageResult(items, hasMore = page < chunks.size) val info = PackageUtil.getPackageInfo(context, packageName)
}.flow.distinctUntilChanged() info.applicationInfo?.loadLabel(context.packageManager)?.toString() ?: packageName
.cachedIn(viewModelScope) } catch (_: Exception) {
.onEach { _apps.value = it } packageName
.launchIn(viewModelScope) }
private data class EnrichedPackage(
val info: PackageInfo,
override val label: String,
override val installer: String?,
override val sizeBytes: Long,
override val isSystem: Boolean
) : InstalledAppMeta {
override val packageName: String get() = info.packageName
override val firstInstallTime: Long get() = info.firstInstallTime
override val lastUpdateTime: Long get() = info.lastUpdateTime
}
companion object {
private val PREF_KEYS = SortFilterPrefKeys(
sortBy = Preferences.PREFERENCE_INSTALLED_SORT_BY,
sortOrder = Preferences.PREFERENCE_INSTALLED_SORT_ORDER,
appTypes = Preferences.PREFERENCE_INSTALLED_APP_TYPES,
installer = Preferences.PREFERENCE_INSTALLED_INSTALLER
)
} }
} }

View File

@@ -7,6 +7,7 @@
package com.aurora.store.viewmodel.blacklist 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 { fun updateState(newState: SortFilterState) {
it.displayName.contains(query, true) || if (newState == _state.value) return
it.packageName.contains(query, true) _state.value = newState
} newState.save(context, PREF_KEYS)
} else { }
_filteredPackages.value = allPackages
} 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
)
}
} }

View File

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