blacklist: fix search & group selected apps under sticky headers

This commit is contained in:
Rahul Patel
2026-05-08 01:27:09 +05:30
parent 3060133485
commit 2073608970
4 changed files with 157 additions and 45 deletions

View File

@@ -5,10 +5,12 @@
package com.aurora.store.compose.ui.blacklist
import android.content.pm.PackageInfo
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,10 +23,13 @@ import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.AppBarWithSearch
import androidx.compose.material3.ExpandedFullScreenSearchBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
@@ -46,20 +51,22 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.core.content.pm.PackageInfoCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.Constants
import com.aurora.extensions.toast
import com.aurora.store.R
import com.aurora.store.compose.composable.BlackListItem
import com.aurora.store.compose.composable.ContainedLoadingIndicator
import com.aurora.store.compose.composable.ScrollHint
import com.aurora.store.compose.composable.TextDividerComposable
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.blacklist.menu.BlacklistMenu
import com.aurora.store.compose.ui.blacklist.menu.MenuItem
import com.aurora.store.util.PackageUtil
import com.aurora.store.data.model.BlacklistAppItem
import com.aurora.store.viewmodel.blacklist.BlacklistViewModel
import java.util.Calendar
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -72,7 +79,6 @@ fun BlacklistScreen(onNavigateUp: () -> Unit, viewModel: BlacklistViewModel = hi
packages = packages,
onNavigateUp = onNavigateUp,
isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist },
isPackageFiltered = { pkgInfo -> viewModel.isFiltered(pkgInfo) },
onBlacklistImport = { uri ->
viewModel.importBlacklist(uri)
context.toast(R.string.toast_black_import_success)
@@ -91,10 +97,9 @@ fun BlacklistScreen(onNavigateUp: () -> Unit, viewModel: BlacklistViewModel = hi
@Composable
private fun ScreenContent(
packages: List<PackageInfo>? = null,
packages: List<BlacklistAppItem>? = null,
onNavigateUp: () -> Unit = {},
isPackageBlacklisted: (packageName: String) -> Boolean = { false },
isPackageFiltered: (packageInfo: PackageInfo) -> Boolean = { false },
onBlacklistImport: (uri: Uri) -> Unit = {},
onBlacklistExport: (uri: Uri) -> Unit = {},
onBlacklist: (packageName: String) -> Unit = {},
@@ -119,6 +124,7 @@ private fun ScreenContent(
}
}
)
val docExportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(Constants.JSON_MIME_TYPE),
onResult = {
@@ -164,11 +170,23 @@ private fun ScreenContent(
@Composable
fun SearchBar() {
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collectLatest { interaction ->
if (interaction is PressInteraction.Press) {
awaitFrame()
focusRequester.requestFocus()
}
}
}
val inputField = @Composable {
SearchBarDefaults.InputField(
modifier = Modifier.focusRequester(focusRequester),
searchBarState = searchBarState,
textFieldState = textFieldState,
interactionSource = interactionSource,
onSearch = { query -> onRequestSearch(query) },
placeholder = {
Text(
@@ -212,11 +230,30 @@ private fun ScreenContent(
)
}
},
actions = { if (!packages.isNullOrEmpty()) SetupMenu() },
actions = { SetupMenu() },
colors = SearchBarDefaults.appBarWithSearchColors(
appBarContainerColor = Color.Transparent
)
)
ExpandedFullScreenSearchBar(state = searchBarState, inputField = inputField) {
if (textFieldState.text.length >= 3) {
packages?.take(10)?.forEach { pkg ->
Text(
text = pkg.displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.clickable { onRequestSearch(pkg.displayName) }
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
)
)
}
}
}
}
Scaffold(
@@ -228,28 +265,24 @@ private fun ScreenContent(
.fillMaxSize()
.padding(paddingValues)
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_xxsmall)
)
) {
items(items = packages ?: emptyList(), key = { p ->
p.packageName.hashCode()
}) { pkg ->
if (packages == null) {
ContainedLoadingIndicator()
} else {
val (selectedPackages, otherPackages) = packages.partition { pkg ->
isPackageBlacklisted(pkg.packageName) || pkg.isFiltered
}
@Composable
fun BlacklistRow(pkg: BlacklistAppItem) {
val isBlacklisted = isPackageBlacklisted(pkg.packageName)
val isFiltered = isPackageFiltered(pkg)
BlackListItem(
icon = PackageUtil.getIconForPackage(context, pkg.packageName)!!,
displayName = pkg.applicationInfo!!.loadLabel(
context.packageManager
).toString(),
icon = pkg.icon,
displayName = pkg.displayName,
packageName = pkg.packageName,
versionName = pkg.versionName!!,
versionCode = PackageInfoCompat.getLongVersionCode(pkg),
isChecked = isBlacklisted || isFiltered,
isEnabled = !isFiltered,
versionName = pkg.versionName,
versionCode = pkg.versionCode,
isChecked = isBlacklisted || pkg.isFiltered,
isEnabled = !pkg.isFiltered,
onClick = {
if (isBlacklisted) {
onWhitelist(pkg.packageName)
@@ -259,12 +292,50 @@ private fun ScreenContent(
}
)
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_xxsmall)
)
) {
if (selectedPackages.isNotEmpty()) {
stickyHeader(key = "header_selected") {
Surface(modifier = Modifier.fillMaxWidth()) {
TextDividerComposable(
title = stringResource(
R.string.header_blacklist_selected
)
)
}
}
items(items = selectedPackages, key = { p ->
p.packageName.hashCode()
}) { pkg -> BlacklistRow(pkg) }
}
if (otherPackages.isNotEmpty()) {
stickyHeader(key = "header_others") {
Surface(modifier = Modifier.fillMaxWidth()) {
TextDividerComposable(
title = stringResource(
R.string.header_blacklist_others
)
)
}
}
items(items = otherPackages, key = { p ->
p.packageName.hashCode()
}) { pkg -> BlacklistRow(pkg) }
}
}
ScrollHint(
listState = listState,
bottomPadding = 5.dp,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
ScrollHint(
listState = listState,
bottomPadding = 5.dp,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}

View File

@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.model
import android.graphics.Bitmap
data class BlacklistAppItem(
val packageName: String,
val displayName: String,
val versionName: String,
val versionCode: Long,
val icon: Bitmap,
val isFiltered: Boolean
)

View File

@@ -10,12 +10,14 @@ import android.content.pm.PackageInfo
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.core.content.pm.PackageInfoCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aurora.extensions.TAG
import com.aurora.store.AuroraApp
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.helper.UpdateHelper
import com.aurora.store.data.model.BlacklistAppItem
import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.util.CertUtil
import com.aurora.store.util.PackageUtil
@@ -44,8 +46,8 @@ class BlacklistViewModel @Inject constructor(
private val isExtendedUpdateEnabled =
Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED)
private val packages = MutableStateFlow<List<PackageInfo>?>(null)
private val _filteredPackages = MutableStateFlow<List<PackageInfo>?>(null)
private val packages = MutableStateFlow<List<BlacklistAppItem>?>(null)
private val _filteredPackages = MutableStateFlow<List<BlacklistAppItem>?>(null)
val filteredPackages = _filteredPackages.asStateFlow()
val blacklist = mutableStateListOf<String>()
@@ -58,28 +60,42 @@ class BlacklistViewModel @Inject constructor(
private fun fetchApps() {
viewModelScope.launch(Dispatchers.IO) {
try {
packages.value = PackageUtil.getAllValidPackages(context).also { pkgList ->
_filteredPackages.value = pkgList
}
packages.value = PackageUtil.getAllValidPackages(context)
.mapNotNull { it.toBlacklistAppItem() }
.also { _filteredPackages.value = it }
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch apps", exception)
}
}
}
private fun PackageInfo.toBlacklistAppItem(): BlacklistAppItem? {
val icon = PackageUtil.getIconForPackage(context, packageName) ?: return null
return BlacklistAppItem(
packageName = packageName,
displayName = applicationInfo!!.loadLabel(context.packageManager).toString(),
versionName = versionName ?: "",
versionCode = PackageInfoCompat.getLongVersionCode(this),
icon = icon,
isFiltered = isFiltered(this)
)
}
fun search(query: String) {
if (query.isNotBlank()) {
_filteredPackages.value = packages.value!!
.filter {
it.applicationInfo!!.loadLabel(context.packageManager).contains(query, true) ||
viewModelScope.launch(Dispatchers.Default) {
val allPackages = packages.value ?: return@launch
if (query.isNotBlank()) {
_filteredPackages.value = allPackages.filter {
it.displayName.contains(query, true) ||
it.packageName.contains(query, true)
}
} else {
_filteredPackages.value = packages.value
} else {
_filteredPackages.value = allPackages
}
}
}
fun isFiltered(packageInfo: PackageInfo): Boolean = when {
private fun isFiltered(packageInfo: PackageInfo): Boolean = when {
!isExtendedUpdateEnabled && !packageInfo.applicationInfo!!.enabled -> true
isAuroraOnlyFilterEnabled -> !CertUtil.isAuroraStoreApp(
@@ -99,7 +115,8 @@ class BlacklistViewModel @Inject constructor(
}
fun blacklistAll() {
blacklistProvider.blacklist = packages.value!!.map { it.packageName }.toMutableSet()
val allPackages = packages.value ?: return
blacklistProvider.blacklist = allPackages.map { it.packageName }.toMutableSet()
blacklist.apply {
clear()
addAll(blacklistProvider.blacklist)
@@ -126,7 +143,10 @@ class BlacklistViewModel @Inject constructor(
)
val validImportedSet = importedSet
.filter { pkgName -> packages.value!!.any { it.packageName == pkgName } }
.filter { pkgName ->
packages.value?.any { it.packageName == pkgName } ==
true
}
blacklistProvider.blacklist.addAll(validImportedSet)
blacklist.apply {
clear()

View File

@@ -493,6 +493,10 @@
<string name="default_spoof">Default</string>
<string name="available_spoof">Available</string>
<!-- BlacklistFragment -->
<string name="header_blacklist_selected">Blacklisted</string>
<string name="header_blacklist_others">Others</string>
<!-- AppDetailsFragment -->
<string name="no_description">No description available</string>
<string name="permissions_requested"><xliff:g id="number_of_permissions">%1$d</xliff:g> permissions</string>