blacklist: fix search & group selected apps under sticky headers
This commit is contained in:
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user