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 package com.aurora.store.compose.ui.blacklist
import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.AppBarWithSearch import androidx.compose.material3.AppBarWithSearch
import androidx.compose.material3.ExpandedFullScreenSearchBar
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable 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.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.pm.PackageInfoCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.Constants import com.aurora.Constants
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composable.BlackListItem 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.ScrollHint
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.util.PackageUtil 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
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -72,7 +79,6 @@ fun BlacklistScreen(onNavigateUp: () -> Unit, viewModel: BlacklistViewModel = hi
packages = packages, packages = packages,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist }, isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist },
isPackageFiltered = { pkgInfo -> viewModel.isFiltered(pkgInfo) },
onBlacklistImport = { uri -> onBlacklistImport = { uri ->
viewModel.importBlacklist(uri) viewModel.importBlacklist(uri)
context.toast(R.string.toast_black_import_success) context.toast(R.string.toast_black_import_success)
@@ -91,10 +97,9 @@ fun BlacklistScreen(onNavigateUp: () -> Unit, viewModel: BlacklistViewModel = hi
@Composable @Composable
private fun ScreenContent( private fun ScreenContent(
packages: List<PackageInfo>? = null, packages: List<BlacklistAppItem>? = null,
onNavigateUp: () -> Unit = {}, onNavigateUp: () -> Unit = {},
isPackageBlacklisted: (packageName: String) -> Boolean = { false }, isPackageBlacklisted: (packageName: String) -> Boolean = { false },
isPackageFiltered: (packageInfo: PackageInfo) -> Boolean = { false },
onBlacklistImport: (uri: Uri) -> Unit = {}, onBlacklistImport: (uri: Uri) -> Unit = {},
onBlacklistExport: (uri: Uri) -> Unit = {}, onBlacklistExport: (uri: Uri) -> Unit = {},
onBlacklist: (packageName: String) -> Unit = {}, onBlacklist: (packageName: String) -> Unit = {},
@@ -119,6 +124,7 @@ private fun ScreenContent(
} }
} }
) )
val docExportLauncher = rememberLauncherForActivityResult( val docExportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(Constants.JSON_MIME_TYPE), contract = ActivityResultContracts.CreateDocument(Constants.JSON_MIME_TYPE),
onResult = { onResult = {
@@ -164,11 +170,23 @@ private fun ScreenContent(
@Composable @Composable
fun SearchBar() { fun SearchBar() {
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collectLatest { interaction ->
if (interaction is PressInteraction.Press) {
awaitFrame()
focusRequester.requestFocus()
}
}
}
val inputField = @Composable { val inputField = @Composable {
SearchBarDefaults.InputField( SearchBarDefaults.InputField(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
searchBarState = searchBarState, searchBarState = searchBarState,
textFieldState = textFieldState, textFieldState = textFieldState,
interactionSource = interactionSource,
onSearch = { query -> onRequestSearch(query) }, onSearch = { query -> onRequestSearch(query) },
placeholder = { placeholder = {
Text( Text(
@@ -212,11 +230,30 @@ private fun ScreenContent(
) )
} }
}, },
actions = { if (!packages.isNullOrEmpty()) SetupMenu() }, actions = { SetupMenu() },
colors = SearchBarDefaults.appBarWithSearchColors( colors = SearchBarDefaults.appBarWithSearchColors(
appBarContainerColor = Color.Transparent 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( Scaffold(
@@ -228,28 +265,24 @@ private fun ScreenContent(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
LazyColumn( if (packages == null) {
modifier = Modifier.fillMaxWidth(), ContainedLoadingIndicator()
state = listState, } else {
verticalArrangement = Arrangement.spacedBy( val (selectedPackages, otherPackages) = packages.partition { pkg ->
dimensionResource(R.dimen.margin_xxsmall) isPackageBlacklisted(pkg.packageName) || pkg.isFiltered
) }
) {
items(items = packages ?: emptyList(), key = { p -> @Composable
p.packageName.hashCode() fun BlacklistRow(pkg: BlacklistAppItem) {
}) { pkg ->
val isBlacklisted = isPackageBlacklisted(pkg.packageName) val isBlacklisted = isPackageBlacklisted(pkg.packageName)
val isFiltered = isPackageFiltered(pkg)
BlackListItem( BlackListItem(
icon = PackageUtil.getIconForPackage(context, pkg.packageName)!!, icon = pkg.icon,
displayName = pkg.applicationInfo!!.loadLabel( displayName = pkg.displayName,
context.packageManager
).toString(),
packageName = pkg.packageName, packageName = pkg.packageName,
versionName = pkg.versionName!!, versionName = pkg.versionName,
versionCode = PackageInfoCompat.getLongVersionCode(pkg), versionCode = pkg.versionCode,
isChecked = isBlacklisted || isFiltered, isChecked = isBlacklisted || pkg.isFiltered,
isEnabled = !isFiltered, isEnabled = !pkg.isFiltered,
onClick = { onClick = {
if (isBlacklisted) { if (isBlacklisted) {
onWhitelist(pkg.packageName) 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.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.core.content.pm.PackageInfoCompat
import androidx.lifecycle.ViewModel 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.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.providers.BlacklistProvider import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.util.CertUtil import com.aurora.store.util.CertUtil
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
@@ -44,8 +46,8 @@ class BlacklistViewModel @Inject constructor(
private val isExtendedUpdateEnabled = private val isExtendedUpdateEnabled =
Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED) Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED)
private val packages = MutableStateFlow<List<PackageInfo>?>(null) private val packages = MutableStateFlow<List<BlacklistAppItem>?>(null)
private val _filteredPackages = MutableStateFlow<List<PackageInfo>?>(null) private val _filteredPackages = MutableStateFlow<List<BlacklistAppItem>?>(null)
val filteredPackages = _filteredPackages.asStateFlow() val filteredPackages = _filteredPackages.asStateFlow()
val blacklist = mutableStateListOf<String>() val blacklist = mutableStateListOf<String>()
@@ -58,28 +60,42 @@ 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).also { pkgList -> packages.value = PackageUtil.getAllValidPackages(context)
_filteredPackages.value = pkgList .mapNotNull { it.toBlacklistAppItem() }
} .also { _filteredPackages.value = it }
} catch (exception: Exception) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch apps", 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) { fun search(query: String) {
if (query.isNotBlank()) { viewModelScope.launch(Dispatchers.Default) {
_filteredPackages.value = packages.value!! val allPackages = packages.value ?: return@launch
.filter { if (query.isNotBlank()) {
it.applicationInfo!!.loadLabel(context.packageManager).contains(query, true) || _filteredPackages.value = allPackages.filter {
it.displayName.contains(query, true) ||
it.packageName.contains(query, true) it.packageName.contains(query, true)
} }
} else { } else {
_filteredPackages.value = packages.value _filteredPackages.value = allPackages
}
} }
} }
fun isFiltered(packageInfo: PackageInfo): Boolean = when { private fun isFiltered(packageInfo: PackageInfo): Boolean = when {
!isExtendedUpdateEnabled && !packageInfo.applicationInfo!!.enabled -> true !isExtendedUpdateEnabled && !packageInfo.applicationInfo!!.enabled -> true
isAuroraOnlyFilterEnabled -> !CertUtil.isAuroraStoreApp( isAuroraOnlyFilterEnabled -> !CertUtil.isAuroraStoreApp(
@@ -99,7 +115,8 @@ class BlacklistViewModel @Inject constructor(
} }
fun blacklistAll() { fun blacklistAll() {
blacklistProvider.blacklist = packages.value!!.map { it.packageName }.toMutableSet() val allPackages = packages.value ?: return
blacklistProvider.blacklist = allPackages.map { it.packageName }.toMutableSet()
blacklist.apply { blacklist.apply {
clear() clear()
addAll(blacklistProvider.blacklist) addAll(blacklistProvider.blacklist)
@@ -126,7 +143,10 @@ class BlacklistViewModel @Inject constructor(
) )
val validImportedSet = importedSet val validImportedSet = importedSet
.filter { pkgName -> packages.value!!.any { it.packageName == pkgName } } .filter { pkgName ->
packages.value?.any { it.packageName == pkgName } ==
true
}
blacklistProvider.blacklist.addAll(validImportedSet) blacklistProvider.blacklist.addAll(validImportedSet)
blacklist.apply { blacklist.apply {
clear() clear()

View File

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