From 051dfd6b0b4f0f598e99ae43a9bec3b1bde283c7 Mon Sep 17 00:00:00 2001 From: jeeneo Date: Sat, 28 Mar 2026 22:09:27 -0400 Subject: [PATCH 1/2] feat. quick blocklist implementation copies the custom button logic --- .../compose/settings/SettingsScreen.kt | 32 +- .../compose/settings/SettingsViewModel.kt | 68 ++- .../components/BlacklistSettingItem.kt | 311 +++++++++++ .../com/looker/droidify/data/AppRepository.kt | 38 +- .../looker/droidify/database/CursorOwner.kt | 72 ++- .../com/looker/droidify/database/Database.kt | 160 +++--- .../datastore/AppBlacklistRepository.kt | 171 ++++++ .../datastore/model/BlacklistEntry.kt | 11 + .../com/looker/droidify/di/RepoModule.kt | 5 +- .../droidify/ui/appDetail/AppDetailAdapter.kt | 491 ++++++++---------- .../ui/appDetail/AppDetailFragment.kt | 109 ++-- .../ui/appDetail/AppDetailViewModel.kt | 81 +-- .../droidify/ui/appList/AppListViewModel.kt | 47 +- app/src/main/res/drawable/ic_block.xml | 10 + app/src/main/res/values/strings.xml | 13 + 15 files changed, 1130 insertions(+), 489 deletions(-) create mode 100644 app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt create mode 100644 app/src/main/kotlin/com/looker/droidify/datastore/AppBlacklistRepository.kt create mode 100644 app/src/main/kotlin/com/looker/droidify/datastore/model/BlacklistEntry.kt create mode 100644 app/src/main/res/drawable/ic_block.xml diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt index 54c7c0330..e64859938 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsScreen.kt @@ -29,6 +29,7 @@ import com.looker.droidify.compose.components.BackButton import com.looker.droidify.compose.settings.SettingsViewModel.Companion.cleanUpIntervals import com.looker.droidify.compose.settings.SettingsViewModel.Companion.localeCodesList import com.looker.droidify.compose.settings.components.ActionSettingItem +import com.looker.droidify.compose.settings.components.BlacklistSettingItem import com.looker.droidify.compose.settings.components.CustomButtonsSettingItem import com.looker.droidify.compose.settings.components.SelectionSettingItem import com.looker.droidify.compose.settings.components.SettingHeader @@ -44,13 +45,14 @@ import com.looker.droidify.utility.common.SdkCheck import com.looker.droidify.utility.common.extension.openLink import com.looker.droidify.utility.common.isIgnoreBatteryEnabled import com.looker.droidify.utility.common.requestBatteryFreedom -import java.util.* +import java.util.Locale import kotlin.time.Duration private const val BACKUP_MIME_TYPE = "application/json" private const val SETTINGS_BACKUP_NAME = "droidify_settings" private const val REPO_BACKUP_NAME = "droidify_repos" private const val CUSTOM_BUTTONS_BACKUP_NAME = "custom_buttons" +private const val APP_BLACKLIST_BACKUP_NAME = "app_blacklist" private const val FOXY_DROID_TITLE = "FoxyDroid" private const val FOXY_DROID_URL = "https://github.com/kitsunyan/foxy-droid" @@ -66,6 +68,7 @@ fun SettingsScreen( val context = LocalContext.current val settings by viewModel.settings.collectAsStateWithLifecycle() val customButtons by viewModel.customButtons.collectAsStateWithLifecycle() + val appBlacklist by viewModel.appBlacklist.collectAsStateWithLifecycle() val isBackgroundAllowed by viewModel.isBackgroundAllowed.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -114,6 +117,20 @@ fun SettingsScreen( } } + val exportBlacklistLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(BACKUP_MIME_TYPE), + ) { uri -> uri?.let { viewModel.exportBlacklist(it) } } + + val importBlacklistLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri != null) { + viewModel.importBlacklist(uri) + } else { + viewModel.showSnackbar(R.string.file_format_error_DESC) + } + } + Scaffold( topBar = { TopAppBar( @@ -371,6 +388,19 @@ fun SettingsScreen( ) } + item { SettingHeader(title = stringResource(R.string.app_blacklist_section)) } + + item { + BlacklistSettingItem( + entries = appBlacklist, + onAddEntry = viewModel::addBlacklistEntry, + onUpdateEntry = viewModel::updateBlacklistEntry, + onRemoveEntry = viewModel::removeBlacklistEntry, + onExport = { exportBlacklistLauncher.launch(APP_BLACKLIST_BACKUP_NAME) }, + onImport = { importBlacklistLauncher.launch(arrayOf(BACKUP_MIME_TYPE)) }, + ) + } + item { SettingHeader(title = stringResource(R.string.credits)) } item { diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt index 7d48b5f88..a515f79d0 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/SettingsViewModel.kt @@ -14,10 +14,12 @@ import com.looker.droidify.data.PrivacyRepository import com.looker.droidify.data.StringHandler import com.looker.droidify.database.Database import com.looker.droidify.database.RepositoryExporter +import com.looker.droidify.datastore.AppBlacklistRepository import com.looker.droidify.datastore.CustomButtonRepository import com.looker.droidify.datastore.Settings import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.model.AutoSync +import com.looker.droidify.datastore.model.BlacklistEntry import com.looker.droidify.datastore.model.CustomButton import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.datastore.model.LegacyInstallerComponent @@ -33,15 +35,15 @@ import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.utility.common.extension.updateAsMutable import com.looker.droidify.work.CleanUpWorker import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.* -import javax.inject.Inject -import kotlin.time.Duration -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours @HiltViewModel class SettingsViewModel @Inject constructor( @@ -49,6 +51,7 @@ class SettingsViewModel @Inject constructor( private val privacyRepository: PrivacyRepository, private val repositoryExporter: RepositoryExporter, private val customButtonRepository: CustomButtonRepository, + private val appBlacklistRepository: AppBlacklistRepository, private val handler: StringHandler, ) : ViewModel() { @@ -56,8 +59,10 @@ class SettingsViewModel @Inject constructor( val settings = settingsRepository.data.asStateFlow(Settings()) - val customButtons: StateFlow> = customButtonRepository.buttons - .asStateFlow(emptyList()) + val customButtons: StateFlow> = + customButtonRepository.buttons.asStateFlow(emptyList()) + val appBlacklist: StateFlow> = + appBlacklistRepository.entries.asStateFlow(emptyList()) private val _isBackgroundAllowed = MutableStateFlow(true) val isBackgroundAllowed = _isBackgroundAllowed.asStateFlow() @@ -155,6 +160,7 @@ class SettingsViewModel @Inject constructor( settingsRepository.setDeleteApkOnInstall(false) settingsRepository.setInstallerType(installerType) } + else -> settingsRepository.setInstallerType(installerType) } } @@ -300,7 +306,48 @@ class SettingsViewModel @Inject constructor( }, onFailure = { showSnackbar(R.string.file_format_error_DESC) - } + }, + ) + } + } + + fun addBlacklistEntry(entry: BlacklistEntry) { + viewModelScope.launch { + appBlacklistRepository.addEntry(entry) + } + } + + fun updateBlacklistEntry(entry: BlacklistEntry) { + viewModelScope.launch { + appBlacklistRepository.updateEntry(entry) + } + } + + fun removeBlacklistEntry(entryId: String) { + viewModelScope.launch { + appBlacklistRepository.removeEntry(entryId) + } + } + + fun exportBlacklist(uri: Uri) { + viewModelScope.launch { + appBlacklistRepository.exportToUri(uri).onFailure { + showSnackbar(R.string.file_format_error_DESC) + } + } + } + + fun importBlacklist(uri: Uri) { + viewModelScope.launch { + appBlacklistRepository.importFromUri(uri).fold( + onSuccess = { count -> + if (count > 0) { + showSnackbar(R.string.app_blacklist_imported) + } + }, + onFailure = { + showSnackbar(R.string.file_format_error_DESC) + }, ) } } @@ -315,9 +362,8 @@ class SettingsViewModel @Inject constructor( Duration.INFINITE, ) - val localeCodesList: List = BuildConfig.DETECTED_LOCALES - .toList() - .updateAsMutable { add(0, "system") } + val localeCodesList: List = + BuildConfig.DETECTED_LOCALES.toList().updateAsMutable { add(0, "system") } } } diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt new file mode 100644 index 000000000..eb1e3364d --- /dev/null +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt @@ -0,0 +1,311 @@ +package com.looker.droidify.compose.settings.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.looker.droidify.R +import com.looker.droidify.datastore.model.BlacklistEntry + +@Composable +fun BlacklistSettingItem( + entries: List, + onAddEntry: (BlacklistEntry) -> Unit, + onUpdateEntry: (BlacklistEntry) -> Unit, + onRemoveEntry: (String) -> Unit, + onExport: () -> Unit, + onImport: () -> Unit, + modifier: Modifier = Modifier, +) { + var showEditor by remember { mutableStateOf(false) } + var editingEntry by remember { mutableStateOf(null) } + var showMenu by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.app_blacklist_section), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.app_blacklist_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row { + IconButton( + onClick = { + editingEntry = null + showEditor = true + }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.app_blacklist_add), + tint = MaterialTheme.colorScheme.primary, + ) + } + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.app_blacklist_export)) }, + onClick = { + showMenu = false + onExport() + }, + enabled = entries.isNotEmpty(), + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.app_blacklist_import)) }, + onClick = { + showMenu = false + onImport() + }, + ) + } + } + } + } + + if (entries.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + entries.forEach { entry -> + BlacklistEntryItem( + entry = entry, + onEdit = { + editingEntry = entry + showEditor = true + }, + onDelete = { onRemoveEntry(entry.id) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + if (showEditor) { + BlacklistEntryEditor( + existingEntry = editingEntry, + onSave = { entry -> + if (editingEntry != null) { + onUpdateEntry(entry) + } else { + onAddEntry(entry) + } + showEditor = false + editingEntry = null + }, + onDismiss = { + showEditor = false + editingEntry = null + }, + ) + } +} + +@Composable +private fun BlacklistEntryItem( + entry: BlacklistEntry, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDeleteConfirmation by remember { mutableStateOf(false) } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable(onClick = onEdit) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_block), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.packagePattern.ifBlank { "-" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = entry.appNamePattern.ifBlank { "-" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + IconButton(onClick = { showDeleteConfirmation = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text(stringResource(R.string.confirmation)) }, + text = { Text(stringResource(R.string.app_blacklist_delete_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirmation = false + onDelete() + }, + ) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun BlacklistEntryEditor( + existingEntry: BlacklistEntry?, + onSave: (BlacklistEntry) -> Unit, + onDismiss: () -> Unit, +) { + var packagePattern by remember { mutableStateOf(existingEntry?.packagePattern.orEmpty()) } + var appNamePattern by remember { mutableStateOf(existingEntry?.appNamePattern.orEmpty()) } + val canSave = packagePattern.isNotBlank() || appNamePattern.isNotBlank() + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (existingEntry == null) { + stringResource(R.string.app_blacklist_add) + } else { + stringResource(R.string.custom_button_edit) + }, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = packagePattern, + onValueChange = { packagePattern = it }, + label = { Text(stringResource(R.string.app_blacklist_package_pattern)) }, + placeholder = { Text("com.example.*") }, + singleLine = true, + ) + OutlinedTextField( + value = appNamePattern, + onValueChange = { appNamePattern = it }, + label = { Text(stringResource(R.string.app_blacklist_name_pattern)) }, + placeholder = { Text("*example*") }, + singleLine = true, + ) + Text( + text = stringResource(R.string.app_blacklist_pattern_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton( + enabled = canSave, + onClick = { + onSave( + BlacklistEntry( + id = existingEntry?.id ?: java.util.UUID.randomUUID().toString(), + packagePattern = packagePattern, + appNamePattern = appNamePattern, + ), + ) + }, + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/kotlin/com/looker/droidify/data/AppRepository.kt b/app/src/main/kotlin/com/looker/droidify/data/AppRepository.kt index 823cbe62b..ee6876038 100644 --- a/app/src/main/kotlin/com/looker/droidify/data/AppRepository.kt +++ b/app/src/main/kotlin/com/looker/droidify/data/AppRepository.kt @@ -6,23 +6,26 @@ import com.looker.droidify.data.local.model.toApp import com.looker.droidify.data.model.App import com.looker.droidify.data.model.AppMinimal import com.looker.droidify.data.model.PackageName +import com.looker.droidify.datastore.AppBlacklistRepository import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.get +import com.looker.droidify.datastore.model.BlacklistEntry import com.looker.droidify.datastore.model.SortOrder import com.looker.droidify.sync.v2.model.DefaultName import com.looker.droidify.sync.v2.model.Tag -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import javax.inject.Inject class AppRepository @Inject constructor( private val appDao: AppDao, private val repoDao: RepoDao, private val settingsRepository: SettingsRepository, + private val appBlacklistRepository: AppBlacklistRepository, ) { private val localeStream = settingsRepository.get { language } @@ -37,7 +40,7 @@ class AppRepository @Inject constructor( antiFeaturesToExclude: List? = null, ): List = withContext(Dispatchers.Default) { val currentLocale = localeStream.first() - appDao.query( + val queryResult = appDao.query( sortOrder = sortOrder, searchQuery = searchQuery?.ifEmpty { null }, repoId = repoId, @@ -47,6 +50,10 @@ class AppRepository @Inject constructor( antiFeaturesToExclude = antiFeaturesToExclude?.ifEmpty { null }, locale = currentLocale, ) + val blacklistEntries = appBlacklistRepository.getEntries() + queryResult.filterNot { app -> + isBlacklisted(app.packageName.name, app.name, blacklistEntries) + } } val categories: Flow> @@ -68,4 +75,31 @@ class AppRepository @Inject constructor( settingsRepository.toggleFavourites(packageName.name) return !wasInFavourites } + + private fun isBlacklisted( + packageName: String, + appName: String, + entries: List, + ): Boolean { + return entries.any { entry -> + (entry.packagePattern.isNotBlank() && wildcardMatch( + entry.packagePattern, + packageName, + )) || (entry.appNamePattern.isNotBlank() && wildcardMatch( + entry.appNamePattern, + appName, + )) + } + } + + private fun wildcardMatch(pattern: String, value: String): Boolean { + val regexPattern = buildString(pattern.length * 2) { + append("^") + for (char in pattern) { + if (char == '*') append(".*") else append(Regex.escape(char.toString())) + } + append("$") + } + return Regex(regexPattern, RegexOption.IGNORE_CASE).matches(value) + } } diff --git a/app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt b/app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt index b7eb15746..1cc82f1bf 100644 --- a/app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt +++ b/app/src/main/kotlin/com/looker/droidify/database/CursorOwner.kt @@ -18,12 +18,14 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks { val section: ProductItem.Section, val order: SortOrder, val skipSignatureCheck: Boolean = false, + val blacklistPatterns: List = emptyList(), ) : Request(1) data class Installed( val searchQuery: String, val order: SortOrder, val skipSignatureCheck: Boolean = false, + val blacklistPatterns: List = emptyList(), ) : Request(2) data class Updates( @@ -49,9 +51,7 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks { fun attach(callback: Callback, request: Request) { val oldActiveRequest = activeRequests[request.id] - if (oldActiveRequest?.callback != null && - oldActiveRequest.callback != callback && oldActiveRequest.cursor != null - ) { + if (oldActiveRequest?.callback != null && oldActiveRequest.callback != callback && oldActiveRequest.cursor != null) { oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null) } val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) { @@ -79,41 +79,37 @@ class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks { val request = activeRequests[id]!!.request return QueryLoader(requireContext()) { when (request) { - is Request.Available -> - Database.ProductAdapter - .query( - installed = false, - updates = false, - searchQuery = request.searchQuery, - section = request.section, - order = request.order, - signal = it, - skipSignatureCheck = request.skipSignatureCheck, - ) - - is Request.Installed -> - Database.ProductAdapter - .query( - installed = true, - updates = false, - searchQuery = request.searchQuery, - section = ProductItem.Section.All, - order = request.order, - signal = it, - skipSignatureCheck = request.skipSignatureCheck, - ) - - is Request.Updates -> - Database.ProductAdapter - .query( - installed = true, - updates = true, - searchQuery = request.searchQuery, - section = ProductItem.Section.All, - order = request.order, - signal = it, - skipSignatureCheck = request.skipSignatureCheck, - ) + is Request.Available -> Database.ProductAdapter.query( + installed = false, + updates = false, + searchQuery = request.searchQuery, + section = request.section, + order = request.order, + signal = it, + skipSignatureCheck = request.skipSignatureCheck, + blacklistPatterns = request.blacklistPatterns, + ) + + is Request.Installed -> Database.ProductAdapter.query( + installed = true, + updates = false, + searchQuery = request.searchQuery, + section = ProductItem.Section.All, + order = request.order, + signal = it, + skipSignatureCheck = request.skipSignatureCheck, + blacklistPatterns = request.blacklistPatterns, + ) + + is Request.Updates -> Database.ProductAdapter.query( + installed = true, + updates = true, + searchQuery = request.searchQuery, + section = ProductItem.Section.All, + order = request.order, + signal = it, + skipSignatureCheck = request.skipSignatureCheck, + ) is Request.Repositories -> Database.RepositoryAdapter.query(it) } diff --git a/app/src/main/kotlin/com/looker/droidify/database/Database.kt b/app/src/main/kotlin/com/looker/droidify/database/Database.kt index 03f340b76..d892e9eeb 100644 --- a/app/src/main/kotlin/com/looker/droidify/database/Database.kt +++ b/app/src/main/kotlin/com/looker/droidify/database/Database.kt @@ -24,7 +24,6 @@ import com.looker.droidify.utility.serialization.product import com.looker.droidify.utility.serialization.productItem import com.looker.droidify.utility.serialization.repository import com.looker.droidify.utility.serialization.serialize -import java.io.ByteArrayOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay @@ -37,6 +36,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream object Database { fun init(context: Context): Boolean { @@ -277,10 +277,9 @@ object Database { } } - fun getStream(id: Long): Flow = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } - .map { get(id) } - .flowOn(Dispatchers.IO) + fun getStream(id: Long): Flow = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { get(id) }.flowOn(Dispatchers.IO) fun get(id: Long): Repository? { return db.query( @@ -292,22 +291,19 @@ object Database { ).use { it.firstOrNull()?.let(::transform) } } - fun getAllStream(): Flow> = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } - .map { getAll() } - .flowOn(Dispatchers.IO) + fun getAllStream(): Flow> = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { getAll() }.flowOn(Dispatchers.IO) - fun getEnabledStream(): Flow> = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } - .map { getEnabled() } - .flowOn(Dispatchers.IO) + fun getEnabledStream(): Flow> = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { getEnabled() }.flowOn(Dispatchers.IO) private suspend fun getEnabled(): List = withContext(Dispatchers.IO) { db.query( Schema.Repository.name, selection = Pair( - "${Schema.Repository.ROW_ENABLED} != 0 AND " + - "${Schema.Repository.ROW_DELETED} == 0", + "${Schema.Repository.ROW_ENABLED} != 0 AND " + "${Schema.Repository.ROW_DELETED} == 0", emptyArray(), ), signal = null, @@ -322,18 +318,16 @@ object Database { ).use { it.asSequence().map(::transform).toList() } } - fun getAllRemovedStream(): Flow> = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } - .map { getAllDisabledDeleted() } - .flowOn(Dispatchers.IO) + fun getAllRemovedStream(): Flow> = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Repositories)) } + .map { getAllDisabledDeleted() }.flowOn(Dispatchers.IO) private fun getAllDisabledDeleted(): Map { return db.query( Schema.Repository.name, columns = arrayOf(Schema.Repository.ROW_ID, Schema.Repository.ROW_DELETED), selection = Pair( - "${Schema.Repository.ROW_ENABLED} == 0 OR " + - "${Schema.Repository.ROW_DELETED} != 0", + "${Schema.Repository.ROW_ENABLED} == 0 OR " + "${Schema.Repository.ROW_DELETED} != 0", emptyArray(), ), signal = null, @@ -388,8 +382,7 @@ object Database { fun importRepos(list: List) { db.transaction { val currentAddresses = getAll().map { it.address } - val newRepos = list - .filter { it.address !in currentAddresses } + val newRepos = list.filter { it.address !in currentAddresses } newRepos.forEach { put(it) } removeDuplicates() } @@ -416,11 +409,14 @@ object Database { } object ProductAdapter { + data class BlacklistPattern( + val packageLike: String?, + val appNameLike: String?, + ) - fun getStream(packageName: String): Flow> = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } - .map { get(packageName, null) } - .flowOn(Dispatchers.IO) + fun getStream(packageName: String): Flow> = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { get(packageName, null) }.flowOn(Dispatchers.IO) suspend fun getUpdates(skipSignatureCheck: Boolean): List = withContext(Dispatchers.IO) { @@ -433,18 +429,14 @@ object Database { order = SortOrder.NAME, signal = null, ).use { - it.asSequence() - .map(ProductAdapter::transformItem) - .toList() + it.asSequence().map(ProductAdapter::transformItem).toList() } } - fun getUpdatesStream(skipSignatureCheck: Boolean): Flow> = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } - // Crashes due to immediate retrieval of data? - .onEach { delay(50) } - .map { getUpdates(skipSignatureCheck) } - .flowOn(Dispatchers.IO) + fun getUpdatesStream(skipSignatureCheck: Boolean): Flow> = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + // Crashes due to immediate retrieval of data? + .onEach { delay(50) }.map { getUpdates(skipSignatureCheck) }.flowOn(Dispatchers.IO) fun getAll(): List { return db.query( @@ -487,10 +479,9 @@ object Database { ).use { it.asSequence().map(::transform).toList() } } - fun getCountStream(repositoryId: Long): Flow = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } - .map { getCount(repositoryId) } - .flowOn(Dispatchers.IO) + fun getCountStream(repositoryId: Long): Flow = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { getCount(repositoryId) }.flowOn(Dispatchers.IO) private fun getCount(repositoryId: Long): Int { return db.query( @@ -511,6 +502,7 @@ object Database { section: ProductItem.Section, order: SortOrder, signal: CancellationSignal?, + blacklistPatterns: List = emptyList(), ): Cursor { val builder = QueryBuilder() @@ -572,6 +564,23 @@ object Database { builder += """AND ${Schema.Synthetic.ROW_MATCH_RANK} > 0""" } + if (!updates && blacklistPatterns.isNotEmpty()) { + blacklistPatterns.forEach { pattern -> + val conditions = mutableListOf() + pattern.packageLike?.let { + conditions += "product.${Schema.Product.ROW_PACKAGE_NAME} LIKE ? ESCAPE '\\'" + builder %= it + } + pattern.appNameLike?.let { + conditions += "product.${Schema.Product.ROW_NAME} LIKE ? ESCAPE '\\'" + builder %= it + } + if (conditions.isNotEmpty()) { + builder += "AND NOT (${conditions.joinToString(" OR ")})" + } + } + } + builder += "GROUP BY product.${Schema.Product.ROW_PACKAGE_NAME} HAVING 1" if (updates) { @@ -594,15 +603,14 @@ object Database { } private fun transform(cursor: Cursor): Product { - return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA)) - .jsonParse { - it.product().apply { - this.repositoryId = cursor - .getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) - this.description = cursor - .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION)) - } + return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA)).jsonParse { + it.product().apply { + this.repositoryId = + cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) + this.description = + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DESCRIPTION)) } + } } fun transformPackageName(cursor: Cursor): String { @@ -613,23 +621,23 @@ object Database { return cursor.getBlob(cursor.getColumnIndexOrThrow(Schema.Product.ROW_DATA_ITEM)) .jsonParse { it.productItem().apply { - this.repositoryId = cursor - .getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) - this.packageName = cursor - .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME)) - this.name = cursor - .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME)) - this.summary = cursor - .getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY)) - this.installedVersion = cursor - .getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)) - .orEmpty() - this.compatible = cursor - .getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0 - this.canUpdate = cursor - .getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0 - this.matchRank = cursor - .getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK)) + this.repositoryId = + cursor.getLong(cursor.getColumnIndexOrThrow(Schema.Product.ROW_REPOSITORY_ID)) + this.packageName = + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_PACKAGE_NAME)) + this.name = + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_NAME)) + this.summary = + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Product.ROW_SUMMARY)) + this.installedVersion = + cursor.getString(cursor.getColumnIndexOrThrow(Schema.Installed.ROW_VERSION)) + .orEmpty() + this.compatible = + cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Product.ROW_COMPATIBLE)) != 0 + this.canUpdate = + cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_CAN_UPDATE)) != 0 + this.matchRank = + cursor.getInt(cursor.getColumnIndexOrThrow(Schema.Synthetic.ROW_MATCH_RANK)) } } } @@ -637,10 +645,9 @@ object Database { object CategoryAdapter { - fun getAllStream(): Flow> = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } - .map { getAll() } - .flowOn(Dispatchers.IO) + fun getAllStream(): Flow> = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { getAll() }.flowOn(Dispatchers.IO) private suspend fun getAll(): Set = withContext(Dispatchers.IO) { val builder = QueryBuilder() @@ -662,10 +669,9 @@ object Database { object InstalledAdapter { - fun getStream(packageName: String): Flow = flowOf(Unit) - .onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } - .map { get(packageName, null) } - .flowOn(Dispatchers.IO) + fun getStream(packageName: String): Flow = + flowOf(Unit).onCompletion { if (it == null) emitAll(flowCollection(Subject.Products)) } + .map { get(packageName, null) }.flowOn(Dispatchers.IO) suspend fun get(packageName: String, signal: CancellationSignal?): InstalledItem? = withContext(Dispatchers.IO) { @@ -679,7 +685,7 @@ object Database { ), selection = Pair( "${Schema.Installed.ROW_PACKAGE_NAME} = ?", - arrayOf(packageName) + arrayOf(packageName), ), signal = signal, ).use { it.firstOrNull()?.let(::transform) } @@ -829,12 +835,10 @@ object Database { arrayOf(repository.id.toString()), ) db.execSQL( - "INSERT INTO ${Schema.Product.name} SELECT * " + - "FROM ${Schema.Product.temporaryName}", + "INSERT INTO ${Schema.Product.name} SELECT * " + "FROM ${Schema.Product.temporaryName}", ) db.execSQL( - "INSERT INTO ${Schema.Category.name} SELECT * " + - "FROM ${Schema.Category.temporaryName}", + "INSERT INTO ${Schema.Category.name} SELECT * " + "FROM ${Schema.Category.temporaryName}", ) RepositoryAdapter.putWithoutNotification(repository, true, db) db.execSQL("DROP TABLE IF EXISTS ${Schema.Product.temporaryName}") diff --git a/app/src/main/kotlin/com/looker/droidify/datastore/AppBlacklistRepository.kt b/app/src/main/kotlin/com/looker/droidify/datastore/AppBlacklistRepository.kt new file mode 100644 index 000000000..cf69e1f67 --- /dev/null +++ b/app/src/main/kotlin/com/looker/droidify/datastore/AppBlacklistRepository.kt @@ -0,0 +1,171 @@ +package com.looker.droidify.datastore + +import android.content.Context +import android.net.Uri +import com.looker.droidify.datastore.model.BlacklistEntry +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppBlacklistRepository @Inject constructor( + @param:ApplicationContext private val context: Context, +) { + companion object { + private const val FILE_NAME = "app_blacklist.json" + } + + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true + } + + private val mutex = Mutex() + private var isLoaded = false + private val _entries = MutableStateFlow>(emptyList()) + + val entries: Flow> = flow { + ensureLoaded() + emitAll(_entries) + } + + suspend fun getEntries(): List { + ensureLoaded() + return _entries.value + } + + suspend fun addEntry(entry: BlacklistEntry): Boolean = mutex.withLock { + ensureLoadedInternal() + val normalized = entry.normalize() + if (normalized.packagePattern.isBlank() && normalized.appNamePattern.isBlank()) { + return@withLock false + } + val exists = _entries.value.any { + it.packagePattern.equals( + normalized.packagePattern, + ignoreCase = true, + ) && it.appNamePattern.equals(normalized.appNamePattern, ignoreCase = true) + } + if (exists) return@withLock false + val newEntries = _entries.value + normalized + saveToFile(newEntries) + _entries.value = newEntries + true + } + + suspend fun updateEntry(entry: BlacklistEntry) { + mutex.withLock { + ensureLoadedInternal() + val normalized = entry.normalize() + val newEntries = _entries.value.map { if (it.id == normalized.id) normalized else it } + saveToFile(newEntries) + _entries.value = newEntries + } + } + + suspend fun removeEntry(entryId: String) { + mutex.withLock { + ensureLoadedInternal() + val newEntries = _entries.value.filter { it.id != entryId } + saveToFile(newEntries) + _entries.value = newEntries + } + } + + suspend fun exportToUri(uri: Uri): Result = withContext(Dispatchers.IO) { + runCatching { + ensureLoaded() + val jsonString = json.encodeToString( + ListSerializer(BlacklistEntry.serializer()), + _entries.value, + ) + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonString.toByteArray()) + } ?: throw IllegalStateException("Cannot open output stream") + } + } + + suspend fun importFromUri(uri: Uri): Result = withContext(Dispatchers.IO) { + runCatching { + val inputStream = context.contentResolver.openInputStream(uri) + ?: throw IllegalStateException("Cannot open input stream") + val jsonString = inputStream.bufferedReader().use { it.readText() } + val importedEntries = json.decodeFromString( + ListSerializer(BlacklistEntry.serializer()), + jsonString, + ).map { it.normalize() } + + mutex.withLock { + ensureLoadedInternal() + val existing = _entries.value.map { + it.packagePattern.lowercase() to it.appNamePattern.lowercase() + }.toSet() + val newEntries = importedEntries.filter { + (it.packagePattern.lowercase() to it.appNamePattern.lowercase()) !in existing + } + val mergedEntries = _entries.value + newEntries + saveToFile(mergedEntries) + _entries.value = mergedEntries + newEntries.size + } + } + } + + private suspend fun ensureLoaded() { + if (!isLoaded) { + mutex.withLock { + ensureLoadedInternal() + } + } + } + + private suspend fun ensureLoadedInternal() { + if (!isLoaded) { + _entries.value = loadFromFile().map { it.normalize() } + .filter { it.packagePattern.isNotBlank() || it.appNamePattern.isNotBlank() } + isLoaded = true + } + } + + private suspend fun loadFromFile(): List = withContext(Dispatchers.IO) { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return@withContext emptyList() + try { + val jsonString = file.readText() + json.decodeFromString(ListSerializer(BlacklistEntry.serializer()), jsonString) + } catch (_: Exception) { + emptyList() + } + } + + private suspend fun saveToFile(entries: List) = withContext(Dispatchers.IO) { + try { + val file = File(context.filesDir, FILE_NAME) + val jsonString = json.encodeToString( + ListSerializer(BlacklistEntry.serializer()), + entries, + ) + file.writeText(jsonString) + } catch (_: Exception) { + } + } + + private fun BlacklistEntry.normalize(): BlacklistEntry { + return copy( + packagePattern = packagePattern.trim(), + appNamePattern = appNamePattern.trim(), + ) + } +} diff --git a/app/src/main/kotlin/com/looker/droidify/datastore/model/BlacklistEntry.kt b/app/src/main/kotlin/com/looker/droidify/datastore/model/BlacklistEntry.kt new file mode 100644 index 000000000..eaf99e6f1 --- /dev/null +++ b/app/src/main/kotlin/com/looker/droidify/datastore/model/BlacklistEntry.kt @@ -0,0 +1,11 @@ +package com.looker.droidify.datastore.model + +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class BlacklistEntry( + val id: String = UUID.randomUUID().toString(), + val packagePattern: String = "", + val appNamePattern: String = "", +) diff --git a/app/src/main/kotlin/com/looker/droidify/di/RepoModule.kt b/app/src/main/kotlin/com/looker/droidify/di/RepoModule.kt index 5c51ebd6d..053320c10 100644 --- a/app/src/main/kotlin/com/looker/droidify/di/RepoModule.kt +++ b/app/src/main/kotlin/com/looker/droidify/di/RepoModule.kt @@ -10,6 +10,7 @@ import com.looker.droidify.data.local.dao.AuthDao import com.looker.droidify.data.local.dao.IndexDao import com.looker.droidify.data.local.dao.InstalledDao import com.looker.droidify.data.local.dao.RepoDao +import com.looker.droidify.datastore.AppBlacklistRepository import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.network.Downloader import dagger.Module @@ -51,10 +52,12 @@ object RepoModule { appDao: AppDao, repoDao: RepoDao, settingsRepository: SettingsRepository, + appBlacklistRepository: AppBlacklistRepository, ): AppRepository = AppRepository( appDao = appDao, repoDao = repoDao, settingsRepository = settingsRepository, + appBlacklistRepository = appBlacklistRepository, ) @Provides @@ -64,4 +67,4 @@ object RepoModule { installedDao = installedDao, ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailAdapter.kt b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailAdapter.kt index ce1dcbf37..969d14e63 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailAdapter.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailAdapter.kt @@ -31,7 +31,6 @@ import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.core.text.bold import androidx.core.text.buildSpannedString -import androidx.core.util.TypedValueCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager @@ -76,18 +75,18 @@ import com.looker.droidify.utility.extension.resources.TypefaceExtra import com.looker.droidify.utility.extension.resources.sizeScaled import com.looker.droidify.utility.text.formatHtml import com.looker.droidify.widget.StableRecyclerAdapter +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import kotlinx.parcelize.Parcelize import java.time.format.DateTimeFormatter import java.time.format.FormatStyle -import java.util.* +import java.util.Locale import kotlin.math.PI import kotlin.math.roundToInt import kotlin.math.sin import kotlin.time.ExperimentalTime import kotlin.time.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toJavaLocalDateTime -import kotlinx.datetime.toLocalDateTime -import kotlinx.parcelize.Parcelize import com.google.android.material.R as MaterialR import com.looker.droidify.R.drawable as drawableRes import com.looker.droidify.R.string as stringRes @@ -113,13 +112,22 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } enum class Action(@param:StringRes val titleResId: Int, @param:DrawableRes val iconResId: Int) { - INSTALL(stringRes.install, drawableRes.ic_download), - UPDATE(stringRes.update, drawableRes.ic_download), - LAUNCH(stringRes.launch, drawableRes.ic_launch), - DETAILS(stringRes.details, drawableRes.ic_tune), - UNINSTALL(stringRes.uninstall, drawableRes.ic_delete), - CANCEL(stringRes.cancel, drawableRes.ic_cancel), - SHARE(stringRes.share, drawableRes.ic_share), + INSTALL(stringRes.install, drawableRes.ic_download), UPDATE( + stringRes.update, + drawableRes.ic_download, + ), + LAUNCH(stringRes.launch, drawableRes.ic_launch), DETAILS( + stringRes.details, + drawableRes.ic_tune, + ), + UNINSTALL(stringRes.uninstall, drawableRes.ic_delete), CANCEL( + stringRes.cancel, + drawableRes.ic_cancel, + ), + BLACKLIST(stringRes.app_blacklist_add, drawableRes.ic_block), SHARE( + stringRes.share, + drawableRes.ic_share, + ), SOURCE(stringRes.source_code, drawableRes.ic_source_code), } @@ -133,41 +141,29 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } enum class ViewType { - APP_INFO, - DOWNLOAD_STATUS, - INSTALL_BUTTON, - CUSTOM_BUTTONS, - SCREENSHOT, - SWITCH, - SECTION, - EXPAND, - TEXT, - LINK, - PERMISSIONS, - RELEASE, - EMPTY + APP_INFO, DOWNLOAD_STATUS, INSTALL_BUTTON, CUSTOM_BUTTONS, SCREENSHOT, SWITCH, SECTION, EXPAND, TEXT, LINK, PERMISSIONS, RELEASE, EMPTY } private enum class SwitchType(val titleResId: Int) { - IGNORE_ALL_UPDATES(stringRes.ignore_all_updates), - IGNORE_THIS_UPDATE(stringRes.ignore_this_update) + IGNORE_ALL_UPDATES(stringRes.ignore_all_updates), IGNORE_THIS_UPDATE(stringRes.ignore_this_update) } private enum class SectionType( val titleResId: Int, val colorAttrResId: Int = android.R.attr.colorPrimary, ) { - ANTI_FEATURES(stringRes.anti_features, android.R.attr.colorError), - CHANGES(stringRes.changes), - LINKS(stringRes.links), - DONATE(stringRes.donate), - PERMISSIONS(stringRes.permissions), + ANTI_FEATURES( + stringRes.anti_features, + android.R.attr.colorError, + ), + CHANGES(stringRes.changes), LINKS(stringRes.links), DONATE(stringRes.donate), PERMISSIONS( + stringRes.permissions, + ), VERSIONS(stringRes.versions) } internal enum class ExpandType { - NOTHING, DESCRIPTION, CHANGES, - LINKS, DONATES, PERMISSIONS, VERSIONS + NOTHING, DESCRIPTION, CHANGES, LINKS, DONATES, PERMISSIONS, VERSIONS } private enum class TextType { DESCRIPTION, ANTI_FEATURES, CHANGES } @@ -177,16 +173,19 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val titleResId: Int, val format: ((Context, String) -> String)? = null, ) { - SOURCE(drawableRes.ic_code, stringRes.source_code), - AUTHOR(drawableRes.ic_person, stringRes.author_website), - EMAIL(drawableRes.ic_email, stringRes.author_email), - LICENSE( + SOURCE(drawableRes.ic_code, stringRes.source_code), AUTHOR( + drawableRes.ic_person, + stringRes.author_website, + ), + EMAIL(drawableRes.ic_email, stringRes.author_email), LICENSE( drawableRes.ic_copyright, stringRes.license, - format = { context, text -> context.getString(stringRes.license_FORMAT, text) } + format = { context, text -> context.getString(stringRes.license_FORMAT, text) }, + ), + TRACKER(drawableRes.ic_bug_report, stringRes.bug_tracker), CHANGELOG( + drawableRes.ic_history, + stringRes.changelog, ), - TRACKER(drawableRes.ic_bug_report, stringRes.bug_tracker), - CHANGELOG(drawableRes.ic_history, stringRes.changelog), WEB(drawableRes.ic_public, stringRes.project_website) } @@ -265,7 +264,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : sectionType, ExpandType.NOTHING, emptyList(), - 0 + 0, ) override val descriptor: String @@ -360,8 +359,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val permissions: List, ) : Item() { override val descriptor: String - get() = "permissions.${group?.name}" + - ".${permissions.joinToString(separator = ".") { it.name }}" + get() = "permissions.${group?.name}" + ".${permissions.joinToString(separator = ".") { it.name }}" override val viewType: ViewType get() = ViewType.PERMISSIONS @@ -491,8 +489,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val button = itemView.findViewById(R.id.expand_view_button)!! } - private class TextViewHolder(context: Context) : - RecyclerView.ViewHolder(TextView(context)) { + private class TextViewHolder(context: Context) : RecyclerView.ViewHolder(TextView(context)) { val text: TextView get() = itemView as TextView @@ -504,7 +501,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : movementMethod = LinkMovementMethod() layoutParams = RecyclerView.LayoutParams( RecyclerView.LayoutParams.MATCH_PARENT, - RecyclerView.LayoutParams.WRAP_CONTENT + RecyclerView.LayoutParams.WRAP_CONTENT, ) } } @@ -535,8 +532,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : init { text.typeface = TypefaceExtra.medium val margin = measurement.invalidate(itemView.resources) { - @SuppressLint("SetTextI18n") - text.text = "measure" + @SuppressLint("SetTextI18n") text.text = "measure" link.visibility = View.GONE measurement.measure(itemView) ((itemView.measuredHeight - icon.measuredHeight) / 2f).roundToInt() @@ -558,8 +554,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : init { val margin = measurement.invalidate(itemView.resources) { - @SuppressLint("SetTextI18n") - text.text = "measure" + @SuppressLint("SetTextI18n") text.text = "measure" measurement.measure(itemView) ((itemView.measuredHeight - icon.measuredHeight) / 2f).roundToInt() } @@ -608,7 +603,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : with(itemView as LinearLayout) { layoutParams = RecyclerView.LayoutParams( RecyclerView.LayoutParams.MATCH_PARENT, - RecyclerView.LayoutParams.MATCH_PARENT + RecyclerView.LayoutParams.MATCH_PARENT, ) orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER @@ -644,14 +639,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } for (x in 12..(width - 12)) { val yValue = - ( - ( - sin(x * (2f * PI / waveWidth)) * - (waveHeight / (2)) + - (waveHeight / 2) - ).toFloat() + - (0 - (waveHeight / 2)) - ) + height / 2 + ((sin(x * (2f * PI / waveWidth)) * (waveHeight / (2)) + (waveHeight / 2)).toFloat() + (0 - (waveHeight / 2))) + height / 2 drawPoint(x.toFloat(), yValue, linePaint) } } @@ -681,32 +669,32 @@ class AppDetailAdapter(private val callbacks: Callbacks) : addView( title, LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT + LinearLayout.LayoutParams.WRAP_CONTENT, ) addView( packageName, LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT + LinearLayout.LayoutParams.WRAP_CONTENT, ) addView( imageView, LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT + LinearLayout.LayoutParams.WRAP_CONTENT, ) addView( repoTitle, LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT + LinearLayout.LayoutParams.WRAP_CONTENT, ) addView( repoAddress, LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT + LinearLayout.LayoutParams.WRAP_CONTENT, ) addView( copyRepoAddress, LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT + LinearLayout.LayoutParams.WRAP_CONTENT, ) } } @@ -760,7 +748,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : items += Item.AppInfoItem( productRepository.second, productRepository.first, - downloads + downloads, ) items += Item.DownloadStatusItem @@ -780,7 +768,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : screenShotItem += Item.ScreenshotItem( productRepository.first.screenshots, packageName, - productRepository.second + productRepository.second, ) items += screenShotItem } @@ -790,25 +778,28 @@ class AppDetailAdapter(private val callbacks: Callbacks) : Item.SwitchItem( SwitchType.IGNORE_ALL_UPDATES, packageName, - productRepository.first.versionCode - ) + productRepository.first.versionCode, + ), ) if (productRepository.first.canUpdate(installedItem)) { items.add( Item.SwitchItem( SwitchType.IGNORE_THIS_UPDATE, packageName, - productRepository.first.versionCode - ) + productRepository.first.versionCode, + ), ) } } val textViewHolder = TextViewHolder(context) - val textViewWidthSpec = context.resources.displayMetrics.widthPixels - .let { View.MeasureSpec.makeMeasureSpec(it, View.MeasureSpec.EXACTLY) } - val textViewHeightSpec = - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + val textViewWidthSpec = context.resources.displayMetrics.widthPixels.let { + View.MeasureSpec.makeMeasureSpec( + it, + View.MeasureSpec.EXACTLY, + ) + } + val textViewHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) fun CharSequence.lineCropped(maxLines: Int, cropLines: Int): CharSequence? { assert(cropLines <= maxLines) @@ -818,7 +809,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : 0, 0, textViewHolder.text.measuredWidth, - textViewHolder.text.measuredHeight + textViewHolder.text.measuredHeight, ) val layout = textViewHolder.text.layout val cropLineOffset = @@ -835,16 +826,15 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } val end = when { cropLineOffset < 0 -> -1 - paragraphEndLine >= 0 && paragraphEndLine - (cropLines - 1) <= 3 -> - if (paragraphEndIndex < length) paragraphEndIndex else -1 + paragraphEndLine >= 0 && paragraphEndLine - (cropLines - 1) <= 3 -> if (paragraphEndIndex < length) paragraphEndIndex else -1 else -> cropLineOffset } val length = if (end < 0) { -1 } else { - asSequence().take(end) - .indexOfLast { it != '\n' }.let { if (it >= 0) it + 1 else end } + asSequence().take(end).indexOfLast { it != '\n' } + .let { if (it >= 0) it + 1 else end } } return if (length >= 0) subSequence(0, length) else null } @@ -869,7 +859,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : TypefaceSpan("sans-serif-medium"), 0, productRepository.first.summary.length, - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE, ) } } @@ -878,7 +868,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val cropped = if (ExpandType.DESCRIPTION !in expanded) { description.lineCropped( 12, - 10 + 10, ) } else { null @@ -888,7 +878,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val croppedItem = Item.TextItem(TextType.DESCRIPTION, cropped) items += listOf( croppedItem, - Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item, croppedItem)) + Item.ExpandItem(ExpandType.DESCRIPTION, true, listOf(item, croppedItem)), ) } else { items += item @@ -924,18 +914,17 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val changes = productRepository.first.whatsNew if (changes.isNotEmpty()) { items += Item.SectionItem(SectionType.CHANGES) - val cropped = - if (ExpandType.CHANGES !in expanded) { - changes.lineCropped(12, 10) - } else { - null - } + val cropped = if (ExpandType.CHANGES !in expanded) { + changes.lineCropped(12, 10) + } else { + null + } val item = Item.TextItem(TextType.CHANGES, changes) if (cropped != null) { val croppedItem = Item.TextItem(TextType.CHANGES, cropped) items += listOf( croppedItem, - Item.ExpandItem(ExpandType.CHANGES, true, listOf(item, croppedItem)) + Item.ExpandItem(ExpandType.CHANGES, true, listOf(item, croppedItem)), ) } else { items += item @@ -949,7 +938,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : linkItems += Item.LinkItem.Typed( linkType = LinkType.SOURCE, text = "", - uri = link.toUri() + uri = link.toUri(), ) } } @@ -958,7 +947,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : linkItems += Item.LinkItem.Typed( linkType = LinkType.AUTHOR, text = author.name, - uri = author.web.nullIfEmpty()?.let(Uri::parse) + uri = author.web.nullIfEmpty()?.let(Uri::parse), ) } author.email.nullIfEmpty()?.let { @@ -968,7 +957,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : Item.LinkItem.Typed( linkType = LinkType.LICENSE, text = it, - uri = "https://spdx.org/licenses/$it.html".toUri() + uri = "https://spdx.org/licenses/$it.html".toUri(), ) } tracker.nullIfEmpty() @@ -977,7 +966,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : linkItems += Item.LinkItem.Typed( linkType = LinkType.CHANGELOG, text = "", - uri = it.toUri() + uri = it.toUri(), ) } web.nullIfEmpty() @@ -989,7 +978,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : sectionType = SectionType.LINKS, expandType = ExpandType.LINKS, items = emptyList(), - collapseCount = linkItems.size + collapseCount = linkItems.size, ) items += linkItems } else { @@ -1004,7 +993,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : SectionType.DONATE, ExpandType.DONATES, emptyList(), - donateItems.size + donateItems.size, ) items += donateItems } else { @@ -1012,7 +1001,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : SectionType.DONATE, ExpandType.DONATES, donateItems, - 0 + 0, ) } } @@ -1020,38 +1009,34 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val release = productRepository.first.displayRelease if (release != null) { val packageManager = context.packageManager - val permissions = release.permissions - .asSequence().mapNotNull { - try { - packageManager.getPermissionInfo(it, 0) - } catch (_: Exception) { - null - } + val permissions = release.permissions.asSequence().mapNotNull { + try { + packageManager.getPermissionInfo(it, 0) + } catch (_: Exception) { + null } - .groupBy(PackageItemResolver::getPermissionGroup) - .asSequence().map { (group, permissionInfo) -> + }.groupBy(PackageItemResolver::getPermissionGroup).asSequence() + .map { (group, permissionInfo) -> val permissionGroupInfo = try { group?.let { packageManager.getPermissionGroupInfo(it, 0) } } catch (_: Exception) { null } Pair(permissionGroupInfo, permissionInfo) - } - .groupBy({ it.first }, { it.second }) + }.groupBy({ it.first }, { it.second }) if (permissions.isNotEmpty()) { val permissionsItems = mutableListOf() permissionsItems += permissions.asSequence().filter { it.key != null } .map { Item.PermissionsItem(it.key, it.value.flatten()) } - permissions.asSequence().find { it.key == null } - ?.let { - permissionsItems += Item.PermissionsItem(null, it.value.flatten()) - } + permissions.asSequence().find { it.key == null }?.let { + permissionsItems += Item.PermissionsItem(null, it.value.flatten()) + } if (ExpandType.PERMISSIONS in expanded) { items += Item.SectionItem( SectionType.PERMISSIONS, ExpandType.PERMISSIONS, emptyList(), - permissionsItems.size + permissionsItems.size, ) items += permissionsItems } else { @@ -1059,39 +1044,32 @@ class AppDetailAdapter(private val callbacks: Callbacks) : SectionType.PERMISSIONS, ExpandType.PERMISSIONS, permissionsItems, - 0 + 0, ) } } } - val compatibleReleasePairs = products.asSequence() - .flatMap { (product, repository) -> - product.releases.asSequence() - .filter { allowIncompatibleVersion || it.incompatibilities.isEmpty() } - .map { Pair(it, repository) } - } + val compatibleReleasePairs = products.asSequence().flatMap { (product, repository) -> + product.releases.asSequence() + .filter { allowIncompatibleVersion || it.incompatibilities.isEmpty() } + .map { Pair(it, repository) } + } - val versionsWithMultiSignature = compatibleReleasePairs - .filterNot { release?.signature?.isEmpty() == true } - .map { (release, _) -> release.versionCode to release.signature } - .distinct() - .groupBy { it.first } - .filter { (_, entry) -> entry.size >= 2 } - .keys - - val releaseItems = compatibleReleasePairs - .map { (release, repository) -> - Item.ReleaseItem( - repository = repository, - release = release, - selectedRepository = repository.id == productRepository.second.id, - showSignature = release.versionCode in versionsWithMultiSignature, - reproducible = rblogs.find { it.hash == release.hash }.toReproducible(), - ) - } - .sortedByDescending { it.release.versionCode } - .toList() + val versionsWithMultiSignature = + compatibleReleasePairs.filterNot { release?.signature?.isEmpty() == true } + .map { (release, _) -> release.versionCode to release.signature }.distinct() + .groupBy { it.first }.filter { (_, entry) -> entry.size >= 2 }.keys + + val releaseItems = compatibleReleasePairs.map { (release, repository) -> + Item.ReleaseItem( + repository = repository, + release = release, + selectedRepository = repository.id == productRepository.second.id, + showSignature = release.versionCode in versionsWithMultiSignature, + reproducible = rblogs.find { it.hash == release.hash }.toReproducible(), + ) + }.sortedByDescending { it.release.versionCode }.toList() if (releaseItems.isNotEmpty()) { items += Item.SectionItem(SectionType.VERSIONS) if (releaseItems.size > MAX_RELEASE_ITEMS && ExpandType.VERSIONS !in expanded) { @@ -1099,7 +1077,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : items += Item.ExpandItem( ExpandType.VERSIONS, false, - releaseItems.takeLast(releaseItems.size - MAX_RELEASE_ITEMS) + releaseItems.takeLast(releaseItems.size - MAX_RELEASE_ITEMS), ) } else { items += releaseItems @@ -1152,17 +1130,16 @@ class AppDetailAdapter(private val callbacks: Callbacks) : viewType: ViewType, ): RecyclerView.ViewHolder { return when (viewType) { - ViewType.APP_INFO -> AppInfoViewHolder(parent.inflate(R.layout.app_detail_header)) - .apply { - favouriteButton.setOnClickListener { callbacks.onFavouriteClicked() } - } + ViewType.APP_INFO -> AppInfoViewHolder(parent.inflate(R.layout.app_detail_header)).apply { + favouriteButton.setOnClickListener { callbacks.onFavouriteClicked() } + } ViewType.DOWNLOAD_STATUS -> DownloadStatusViewHolder( - parent.inflate(R.layout.download_status) + parent.inflate(R.layout.download_status), ) ViewType.INSTALL_BUTTON -> InstallButtonViewHolder( - parent.inflate(R.layout.install_button) + parent.inflate(R.layout.install_button), ).apply { button.setOnClickListener { action?.let(callbacks::onActionClick) } } @@ -1176,7 +1153,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : SwitchType.IGNORE_ALL_UPDATES -> { ProductPreferences[switchItem.packageName].let { it.copy( - ignoreUpdates = !it.ignoreUpdates + ignoreUpdates = !it.ignoreUpdates, ) } } @@ -1184,12 +1161,11 @@ class AppDetailAdapter(private val callbacks: Callbacks) : SwitchType.IGNORE_THIS_UPDATE -> { ProductPreferences[switchItem.packageName].let { it.copy( - ignoreVersionCode = - if (it.ignoreVersionCode == switchItem.versionCode) { - 0 - } else { - switchItem.versionCode - } + ignoreVersionCode = if (it.ignoreVersionCode == switchItem.versionCode) { + 0 + } else { + switchItem.versionCode + }, ) } } @@ -1209,7 +1185,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : sectionItem.sectionType, sectionItem.expandType, emptyList(), - sectionItem.items.size + sectionItem.collapseCount + sectionItem.items.size + sectionItem.collapseCount, ) notifyItemChanged(position) items.addAll(position + 1, sectionItem.items) @@ -1221,7 +1197,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : sectionItem.expandType, items.subList(position + 1, position + 1 + sectionItem.collapseCount) .toList(), - 0 + 0, ) notifyItemChanged(position) repeat(sectionItem.collapseCount) { items.removeAt(position + 1) } @@ -1230,41 +1206,40 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } } - ViewType.EXPAND -> ExpandViewHolder(parent.inflate(R.layout.expand_view_button)) - .apply { - itemView.setOnClickListener { - val position = absoluteAdapterPosition - val expandItem = items[position] as Item.ExpandItem - if (expandItem.expandType !in expanded) { - expanded += expandItem.expandType - if (expandItem.replace) { - items[position - 1] = expandItem.items[0] - notifyItemRangeChanged(position - 1, 2) - } else { - items.addAll(position, expandItem.items) - if (position > 0) { - notifyItemRangeInserted(position, expandItem.items.size) - notifyItemChanged(position + expandItem.items.size) - } + ViewType.EXPAND -> ExpandViewHolder(parent.inflate(R.layout.expand_view_button)).apply { + itemView.setOnClickListener { + val position = absoluteAdapterPosition + val expandItem = items[position] as Item.ExpandItem + if (expandItem.expandType !in expanded) { + expanded += expandItem.expandType + if (expandItem.replace) { + items[position - 1] = expandItem.items[0] + notifyItemRangeChanged(position - 1, 2) + } else { + items.addAll(position, expandItem.items) + if (position > 0) { + notifyItemRangeInserted(position, expandItem.items.size) + notifyItemChanged(position + expandItem.items.size) } + } + } else { + expanded -= expandItem.expandType + if (expandItem.replace) { + items[position - 1] = expandItem.items[1] + notifyItemRangeChanged(position - 1, 2) } else { - expanded -= expandItem.expandType - if (expandItem.replace) { - items[position - 1] = expandItem.items[1] - notifyItemRangeChanged(position - 1, 2) - } else { - items.removeAll(expandItem.items) - if (position > 0) { - notifyItemRangeRemoved( - position - expandItem.items.size, - expandItem.items.size - ) - notifyItemChanged(position - expandItem.items.size) - } + items.removeAll(expandItem.items) + if (position > 0) { + notifyItemRangeRemoved( + position - expandItem.items.size, + expandItem.items.size, + ) + notifyItemChanged(position - expandItem.items.size) } } } } + } ViewType.TEXT -> TextViewHolder(parent.context) ViewType.LINK -> LinkViewHolder(parent.inflate(R.layout.link_item)).apply { @@ -1281,16 +1256,15 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } } - ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)) - .apply { - itemView.setOnClickListener { - val permissionsItem = items[absoluteAdapterPosition] as Item.PermissionsItem - callbacks.onPermissionsClick( - permissionsItem.group?.name, - permissionsItem.permissions.map { it.name } - ) - } + ViewType.PERMISSIONS -> PermissionsViewHolder(parent.inflate(R.layout.permissions_item)).apply { + itemView.setOnClickListener { + val permissionsItem = items[absoluteAdapterPosition] as Item.PermissionsItem + callbacks.onPermissionsClick( + permissionsItem.group?.name, + permissionsItem.permissions.map { it.name }, + ) } + } ViewType.RELEASE -> ReleaseViewHolder(parent.inflate(R.layout.release_item)).apply { itemView.setOnClickListener { @@ -1301,7 +1275,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val releaseItem = items[absoluteAdapterPosition] as Item.ReleaseItem copyLinkToClipboard( itemView, - releaseItem.release.getDownloadUrl(releaseItem.repository) + releaseItem.release.getDownloadUrl(releaseItem.repository), ) true } @@ -1338,15 +1312,14 @@ class AppDetailAdapter(private val callbacks: Callbacks) : holder.icon.load(iconUrl) { authentication(item.repository.authentication) } - val authorText = - if (showAuthor) { - buildSpannedString { - append("by ") - bold { append(item.product.author.name) } - } - } else { - buildSpannedString { bold { append(item.product.packageName) } } + val authorText = if (showAuthor) { + buildSpannedString { + append("by ") + bold { append(item.product.author.name) } } + } else { + buildSpannedString { bold { append(item.product.packageName) } } + } holder.authorName.text = authorText holder.packageName.text = authorText if (item.product.author.name.isNotEmpty()) { @@ -1379,7 +1352,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : if (background != null) { setPadding(0, 0, 0, 0) setTextColor( - context.getColorFromAttr(android.R.attr.colorControlNormal) + context.getColorFromAttr(android.R.attr.colorControlNormal), ) background = null } @@ -1419,13 +1392,13 @@ class AppDetailAdapter(private val callbacks: Callbacks) : status.read.toString() } else { "${status.read} / ${status.total}" - } + }, ) holder.progress.isIndeterminate = status.total == null if (status.total != null) { holder.progress.setProgressCompat( status.read.value percentBy status.total.value, - true + true, ) } } @@ -1463,7 +1436,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : holder.actionTintOnCancel } else { holder.actionTintOnNormal - } + }, ) backgroundTintList = if (action == Action.CANCEL) { holder.actionTintCancel @@ -1498,12 +1471,13 @@ class AppDetailAdapter(private val callbacks: Callbacks) : layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) } - val screenshotsAdapter = (adapter as? ScreenshotsAdapter) - ?: ScreenshotsAdapter(callbacks::onScreenshotClick).also { adapter = it } + val screenshotsAdapter = (adapter as? ScreenshotsAdapter) ?: ScreenshotsAdapter( + callbacks::onScreenshotClick, + ).also { adapter = it } screenshotsAdapter.setScreenshots( item.repository, item.packageName, - item.screenshots + item.screenshots, ) } } @@ -1522,8 +1496,9 @@ class AppDetailAdapter(private val callbacks: Callbacks) : LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) } val buttonsAdapter = (adapter as? CustomButtonsAdapter) - ?: CustomButtonsAdapter { url -> callbacks.onCustomButtonClick(url) } - .also { adapter = it } + ?: CustomButtonsAdapter { url -> callbacks.onCustomButtonClick(url) }.also { + adapter = it + } buttonsAdapter.setButtons( buttons = item.buttons, packageName = item.packageName, @@ -1545,9 +1520,8 @@ class AppDetailAdapter(private val callbacks: Callbacks) : SwitchType.IGNORE_THIS_UPDATE -> { val productPreference = ProductPreferences[item.packageName] Pair( - productPreference.ignoreUpdates || - productPreference.ignoreVersionCode == item.versionCode, - !productPreference.ignoreUpdates + productPreference.ignoreUpdates || productPreference.ignoreVersionCode == item.versionCode, + !productPreference.ignoreUpdates, ) } } @@ -1569,7 +1543,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : it.paddingLeft, it.paddingTop, it.paddingRight, - if (expandable) it.paddingTop else 0 + if (expandable) it.paddingTop else 0, ) } val color = context.getColorFromAttr(item.sectionType.colorAttrResId) @@ -1582,7 +1556,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : TooltipCompat.setTooltipText( holder.helpIcon, - context.getString(R.string.rb_badge_info) + context.getString(R.string.rb_badge_info), ) } else { holder.helpIcon.isVisible = false @@ -1646,7 +1620,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : item.group.loadUnbadgedIcon(packageManager) } else { null - } ?: context.getMutatedIcon(drawableRes.ic_perm_device_information) + } ?: context.getMutatedIcon(drawableRes.ic_perm_device_information), ) val localCache = PackageItemResolver.LocalCache() val labels = item.permissions.map { permission -> @@ -1670,40 +1644,38 @@ class AppDetailAdapter(private val callbacks: Callbacks) : } else { Pair( true, - label.first().uppercaseChar() + label.substring(1, label.length) + label.first().uppercaseChar() + label.substring(1, label.length), ) } } val builder = SpannableStringBuilder() - ( - labels.asSequence().filter { it.first } + labels.asSequence() - .filter { !it.first } - ).forEach { - if (builder.isNotEmpty()) { - builder.append("\n\n") - builder.setSpan( - RelativeSizeSpan(1f / 3f), - builder.length - 2, - builder.length, - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - builder.append(it.second) - if (!it.first) { - // Replace dots with spans to enable word wrap - it.second.asSequence() - .mapIndexedNotNull { index, c -> if (c == '.') index else null } - .map { index -> index + builder.length - it.second.length } - .forEach { index -> - builder.setSpan( - DotSpan(), - index, - index + 1, - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - } + (labels.asSequence().filter { it.first } + labels.asSequence() + .filter { !it.first }).forEach { + if (builder.isNotEmpty()) { + builder.append("\n\n") + builder.setSpan( + RelativeSizeSpan(1f / 3f), + builder.length - 2, + builder.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE, + ) } + builder.append(it.second) + if (!it.first) { + // Replace dots with spans to enable word wrap + it.second.asSequence() + .mapIndexedNotNull { index, c -> if (c == '.') index else null } + .map { index -> index + builder.length - it.second.length } + .forEach { index -> + builder.setSpan( + DotSpan(), + index, + index + 1, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + } holder.text.text = builder } @@ -1713,8 +1685,8 @@ class AppDetailAdapter(private val callbacks: Callbacks) : val incompatibility = item.release.incompatibilities.firstOrNull() val singlePlatform = if (item.release.platforms.size == 1) item.release.platforms.first() else null - val installed = installedItem?.versionCode == item.release.versionCode && - installedItem?.signature == item.release.signature + val installed = + installedItem?.versionCode == item.release.versionCode && installedItem?.signature == item.release.signature val suggested = incompatibility == null && item.release.selected && item.selectedRepository @@ -1737,7 +1709,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : installed -> stringRes.installed suggested -> stringRes.suggested else -> stringRes.unknown - } + }, ) background = context.corneredBackground setPadding(15, 15, 15, 15) @@ -1790,10 +1762,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : holder.signature.isVisible = item.showSignature && item.release.signature.isNotEmpty() if (item.showSignature && item.release.signature.isNotEmpty()) { - val bytes = - item.release.signature - .uppercase(Locale.US) - .windowed(2, 2, false) + val bytes = item.release.signature.uppercase(Locale.US).windowed(2, 2, false) val signature = bytes.joinToString(separator = " ") holder.signature.text = signature holder.signature.setOnClickListener { @@ -1809,17 +1778,17 @@ class AppDetailAdapter(private val callbacks: Callbacks) : is Release.Incompatibility.MaxSdk, -> context.getString( stringRes.incompatible_with_FORMAT, - Android.name + Android.name, ) is Release.Incompatibility.Platform -> context.getString( stringRes.incompatible_with_FORMAT, - Android.primaryPlatform ?: context.getString(stringRes.unknown) + Android.primaryPlatform ?: context.getString(stringRes.unknown), ) is Release.Incompatibility.Feature -> context.getString( stringRes.requires_FORMAT, - incompatibility.feature + incompatibility.feature, ) } } else if (singlePlatform != null) { @@ -1848,7 +1817,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : text = context.getString( stringRes.label_sdk_version, targetSdkVersion, - minSdkVersion + minSdkVersion, ) } val enabled = status == Status.Idle @@ -1870,7 +1839,7 @@ class AppDetailAdapter(private val callbacks: Callbacks) : private fun copyLinkToClipboard( view: View, link: String, - snackbarText: Int = stringRes.link_copied_to_clipboard + snackbarText: Int = stringRes.link_copied_to_clipboard, ) { view.context.copyToClipboard(link) Snackbar.make(view, snackbarText, Snackbar.LENGTH_SHORT).show() diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt index d3466df2c..56d8220f5 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailFragment.kt @@ -9,6 +9,7 @@ import android.os.Bundle import android.provider.Settings import android.view.MenuItem import android.view.View +import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.core.os.bundleOf @@ -78,13 +79,19 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { val id: Int, val adapterAction: AppDetailAdapter.Action, ) { - INSTALL(1, AppDetailAdapter.Action.INSTALL), - UPDATE(2, AppDetailAdapter.Action.UPDATE), - LAUNCH(3, AppDetailAdapter.Action.LAUNCH), - DETAILS(4, AppDetailAdapter.Action.DETAILS), - UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL), - SOURCE(6, AppDetailAdapter.Action.SOURCE), - SHARE(7, AppDetailAdapter.Action.SHARE), + INSTALL(1, AppDetailAdapter.Action.INSTALL), UPDATE( + 2, + AppDetailAdapter.Action.UPDATE, + ), + LAUNCH(3, AppDetailAdapter.Action.LAUNCH), DETAILS( + 4, + AppDetailAdapter.Action.DETAILS, + ), + UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL), BLACKLIST( + 6, + AppDetailAdapter.Action.BLACKLIST, + ), + SOURCE(7, AppDetailAdapter.Action.SOURCE), SHARE(8, AppDetailAdapter.Action.SHARE), } private class Installed( @@ -125,10 +132,13 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { mainActivity.onToolbarCreated(toolbar) toolbar.menu.apply { Action.entries.forEach { action -> - add(0, action.id, 0, action.adapterAction.titleResId) - .setIcon(toolbar.context.getMutatedIcon(action.adapterAction.iconResId)) - .setVisible(false) - .setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) + add( + 0, + action.id, + 0, + action.adapterAction.titleResId, + ).setIcon(toolbar.context.getMutatedIcon(action.adapterAction.iconResId)) + .setVisible(false).setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS) .setOnMenuItemClickListener { onActionClick(action.adapterAction) true @@ -246,8 +256,9 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { .let { it != null && it.incompatibilities.isEmpty() } val canInstall = product != null && installed == null && compatible val canUpdate = - product != null && compatible && product.canUpdate(installed?.installedItem) && - !preference.shouldIgnoreUpdate(product.versionCode) + product != null && compatible && product.canUpdate(installed?.installedItem) && !preference.shouldIgnoreUpdate( + product.versionCode, + ) val canUninstall = product != null && installed != null && !installed.isSystem val canLaunch = product != null && installed != null && installed.launcherActivities.isNotEmpty() @@ -264,17 +275,17 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { is Release.Incompatibility.MaxSdk, -> getString( stringRes.incompatible_with_FORMAT, - name + name, ) is Release.Incompatibility.Platform -> getString( stringRes.incompatible_with_FORMAT, - primaryPlatform ?: getString(stringRes.unknown) + primaryPlatform ?: getString(stringRes.unknown), ) is Release.Incompatibility.Feature -> getString( stringRes.requires_FORMAT, - incompatibility.feature + incompatibility.feature, ) } } @@ -297,6 +308,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { if (canLaunch) add(Action.LAUNCH) if (installed != null) add(Action.DETAILS) if (canUninstall) add(Action.UNINSTALL) + add(Action.BLACKLIST) add(Action.SHARE) add(Action.SOURCE) } @@ -329,8 +341,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { } private fun updateToolbarButtons( - isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager) - .findFirstVisibleItemPosition() == 0, + isActionVisible: Boolean = (recyclerView?.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() == 0, ) { toolbar.title = if (isActionVisible) { getString(stringRes.application) @@ -446,6 +457,21 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { AppDetailAdapter.Action.UNINSTALL -> viewModel.uninstallPackage() + AppDetailAdapter.Action.BLACKLIST -> { + val appName = products.firstOrNull()?.first?.name ?: viewModel.packageName + viewLifecycleOwner.lifecycleScope.launch { + val added = viewModel.addToBlacklist(appName) + val messageRes = if (added) { + stringRes.app_blacklist_added + } else { + stringRes.app_blacklist_already_added + } + context?.let { + Toast.makeText(it, getString(messageRes), Toast.LENGTH_SHORT).show() + } + } + } + AppDetailAdapter.Action.CANCEL -> { val binder = downloadConnection.binder if (installing?.isCancellable == true) { @@ -458,19 +484,15 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { AppDetailAdapter.Action.SHARE -> { val repo = products[0].second val address = when { - "https://f-droid.org/repo" in repo.mirrors -> - "https://f-droid.org/packages/${viewModel.packageName}/" + "https://f-droid.org/repo" in repo.mirrors -> "https://f-droid.org/packages/${viewModel.packageName}/" - "https://f-droid.org/archive/repo" in repo.mirrors -> - "https://f-droid.org/packages/${viewModel.packageName}/" + "https://f-droid.org/archive/repo" in repo.mirrors -> "https://f-droid.org/packages/${viewModel.packageName}/" - "https://apt.izzysoft.de/fdroid/repo" in repo.mirrors -> - "https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}" + "https://apt.izzysoft.de/fdroid/repo" in repo.mirrors -> "https://apt.izzysoft.de/fdroid/index/apk/${viewModel.packageName}" else -> shareUrl(viewModel.packageName, repo.address) } - val sendIntent = Intent(Intent.ACTION_SEND) - .putExtra(Intent.EXTRA_TEXT, address) + val sendIntent = Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, address) .setType("text/plain") startActivity(Intent.createChooser(sendIntent, null)) } @@ -493,8 +515,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { private fun startLauncherActivity(name: String) { try { startActivity( - Intent(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER) + Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) .setComponent(ComponentName(viewModel.packageName, name)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), ) @@ -508,8 +529,7 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { } override fun onPermissionsClick(group: String?, permissions: List) { - MessageDialog(Message.Permissions(group, permissions)) - .show(childFragmentManager) + MessageDialog(Message.Permissions(group, permissions)).show(childFragmentManager) } override fun onScreenshotClick(position: Int) { @@ -519,17 +539,16 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { if (it.type == Product.Screenshot.Type.VIDEO) null else it } - imageViewer = StfalconImageViewer - .Builder(context, screenshots) { view, current -> - val screenshotUrl = current.url( - context = requireContext(), - repository = productRepository.second, - packageName = viewModel.packageName, - ) - view.load(screenshotUrl) { - allowHardware(false) - } + imageViewer = StfalconImageViewer.Builder(context, screenshots) { view, current -> + val screenshotUrl = current.url( + context = requireContext(), + repository = productRepository.second, + packageName = viewModel.packageName, + ) + view.load(screenshotUrl) { + allowHardware(false) } + } } imageViewer?.withStartPosition(position) imageViewer?.show() @@ -623,14 +642,10 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks { override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { val names = requireArguments().getStringArrayList(EXTRA_NAMES)!! val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!! - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(stringRes.launch) + return MaterialAlertDialogBuilder(requireContext()).setTitle(stringRes.launch) .setItems(labels.toTypedArray()) { _, position -> - (parentFragment as AppDetailFragment) - .startLauncherActivity(names[position]) - } - .setNegativeButton(stringRes.cancel, null) - .create() + (parentFragment as AppDetailFragment).startLauncherActivity(names[position]) + }.setNegativeButton(stringRes.cancel, null).create() } } } diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt index 532fa87bd..adf22ff26 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appDetail/AppDetailViewModel.kt @@ -9,8 +9,10 @@ import com.looker.droidify.data.PrivacyRepository import com.looker.droidify.data.local.model.RBLogEntity import com.looker.droidify.data.model.toPackageName import com.looker.droidify.database.Database +import com.looker.droidify.datastore.AppBlacklistRepository import com.looker.droidify.datastore.CustomButtonRepository import com.looker.droidify.datastore.SettingsRepository +import com.looker.droidify.datastore.model.BlacklistEntry import com.looker.droidify.datastore.model.CustomButton import com.looker.droidify.datastore.model.InstallerType import com.looker.droidify.installer.InstallManager @@ -27,18 +29,18 @@ import com.looker.droidify.model.Repository import com.looker.droidify.utility.common.extension.asStateFlow import com.looker.droidify.utility.extension.combine import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import javax.inject.Inject @HiltViewModel class AppDetailViewModel @Inject constructor( private val installer: InstallManager, private val settingsRepository: SettingsRepository, private val customButtonRepository: CustomButtonRepository, + private val appBlacklistRepository: AppBlacklistRepository, privacyRepository: PrivacyRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -48,40 +50,38 @@ class AppDetailViewModel @Inject constructor( private val repoAddress: StateFlow = savedStateHandle.getStateFlow(ARG_REPO_ADDRESS, null) - val installerState: StateFlow = - installer.state.mapNotNull { stateMap -> - stateMap[packageName.toPackageName()] - }.asStateFlow(null) - - val customButtons: StateFlow> = customButtonRepository.buttons - .asStateFlow(emptyList()) - - val state = - combine( - Database.ProductAdapter.getStream(packageName), - Database.RepositoryAdapter.getAllStream(), - Database.InstalledAdapter.getStream(packageName), - privacyRepository.getRBLogs(packageName), - privacyRepository.getLatestDownloadStats(packageName), - repoAddress, - settingsRepository.data - ) { products, repositories, installedItem, rblogs, downloads, suggestedAddress, settings -> - val idAndRepos = repositories.associateBy { it.id } - val filteredProducts = products.filter { product -> - idAndRepos[product.repositoryId] != null - } - AppDetailUiState( - products = filteredProducts, - repos = repositories, - rblogs = rblogs, - downloads = downloads, - installedItem = installedItem, - isFavourite = packageName in settings.favouriteApps, - allowIncompatibleVersions = settings.incompatibleVersions, - isSelf = packageName == BuildConfig.APPLICATION_ID, - addressIfUnavailable = suggestedAddress, - ) - }.asStateFlow(AppDetailUiState()) + val installerState: StateFlow = installer.state.mapNotNull { stateMap -> + stateMap[packageName.toPackageName()] + }.asStateFlow(null) + + val customButtons: StateFlow> = + customButtonRepository.buttons.asStateFlow(emptyList()) + + val state = combine( + Database.ProductAdapter.getStream(packageName), + Database.RepositoryAdapter.getAllStream(), + Database.InstalledAdapter.getStream(packageName), + privacyRepository.getRBLogs(packageName), + privacyRepository.getLatestDownloadStats(packageName), + repoAddress, + settingsRepository.data, + ) { products, repositories, installedItem, rblogs, downloads, suggestedAddress, settings -> + val idAndRepos = repositories.associateBy { it.id } + val filteredProducts = products.filter { product -> + idAndRepos[product.repositoryId] != null + } + AppDetailUiState( + products = filteredProducts, + repos = repositories, + rblogs = rblogs, + downloads = downloads, + installedItem = installedItem, + isFavourite = packageName in settings.favouriteApps, + allowIncompatibleVersions = settings.incompatibleVersions, + isSelf = packageName == BuildConfig.APPLICATION_ID, + addressIfUnavailable = suggestedAddress, + ) + }.asStateFlow(AppDetailUiState()) fun shizukuState(context: Context): ShizukuState? { val isSelected = @@ -139,6 +139,15 @@ class AppDetailViewModel @Inject constructor( } } + suspend fun addToBlacklist(appName: String): Boolean { + return appBlacklistRepository.addEntry( + BlacklistEntry( + packagePattern = packageName, + appNamePattern = appName, + ), + ) + } + companion object { const val ARG_PACKAGE_NAME = "package_name" const val ARG_REPO_ADDRESS = "repo_address" diff --git a/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt b/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt index 9f8b0d747..0b5767a35 100644 --- a/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt +++ b/app/src/main/kotlin/com/looker/droidify/ui/appList/AppListViewModel.kt @@ -6,6 +6,7 @@ import com.looker.droidify.database.CursorOwner.Request.Available import com.looker.droidify.database.CursorOwner.Request.Installed import com.looker.droidify.database.CursorOwner.Request.Updates import com.looker.droidify.database.Database +import com.looker.droidify.datastore.AppBlacklistRepository import com.looker.droidify.datastore.SettingsRepository import com.looker.droidify.datastore.get import com.looker.droidify.datastore.model.SortOrder @@ -15,51 +16,52 @@ import com.looker.droidify.service.Connection import com.looker.droidify.service.SyncService import com.looker.droidify.utility.common.extension.asStateFlow import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class AppListViewModel @Inject constructor( settingsRepository: SettingsRepository, + appBlacklistRepository: AppBlacklistRepository, ) : ViewModel() { - private val skipSignatureStream = settingsRepository - .get { ignoreSignature } - .asStateFlow(false) + private val skipSignatureStream = settingsRepository.get { ignoreSignature }.asStateFlow(false) - private val sortOrderFlow = settingsRepository - .get { sortOrder } - .asStateFlow(SortOrder.UPDATED) + private val sortOrderFlow = settingsRepository.get { sortOrder }.asStateFlow(SortOrder.UPDATED) private val sections = MutableStateFlow(All) + private val blacklistPatterns = appBlacklistRepository.entries.asStateFlow(emptyList()) val state = combine( skipSignatureStream, sortOrderFlow, sections, - ) { skipSignature, sortOrder, section -> + blacklistPatterns, + ) { skipSignature, sortOrder, section, blacklist -> AppListState( sections = section, sortOrder = sortOrder, skipSignatureCheck = skipSignature, + blacklistPatterns = blacklist.map { + Database.ProductAdapter.BlacklistPattern( + packageLike = it.packagePattern.takeIf(String::isNotBlank)?.toSqlLikePattern(), + appNameLike = it.appNamePattern.takeIf(String::isNotBlank)?.toSqlLikePattern(), + ) + }, ) }.asStateFlow(AppListState()) - val reposStream = Database.RepositoryAdapter - .getAllStream() - .asStateFlow(emptyList()) + val reposStream = Database.RepositoryAdapter.getAllStream().asStateFlow(emptyList()) @OptIn(ExperimentalCoroutinesApi::class) val showUpdateAllButton = skipSignatureStream.flatMapLatest { skip -> - Database.ProductAdapter - .getUpdatesStream(skip) - .map { it.isNotEmpty() } + Database.ProductAdapter.getUpdatesStream(skip).map { it.isNotEmpty() } }.asStateFlow(false) val syncConnection = Connection(SyncService::class.java) @@ -77,10 +79,25 @@ class AppListViewModel } } +private fun String.toSqlLikePattern(): String { + return buildString(length * 2) { + for (char in this@toSqlLikePattern) { + when (char) { + '\\' -> append("\\\\") + '%' -> append("\\%") + '_' -> append("\\_") + '*' -> append('%') + else -> append(char) + } + } + } +} + data class AppListState( val sections: ProductItem.Section = All, val sortOrder: SortOrder = SortOrder.UPDATED, val skipSignatureCheck: Boolean = false, + val blacklistPatterns: List = emptyList(), ) { fun toRequest(source: AppListFragment.Source, searchQuery: String) = when (source) { AppListFragment.Source.AVAILABLE -> Available( @@ -88,12 +105,14 @@ data class AppListState( section = sections, order = sortOrder, skipSignatureCheck = skipSignatureCheck, + blacklistPatterns = blacklistPatterns, ) AppListFragment.Source.INSTALLED -> Installed( searchQuery = searchQuery, order = sortOrder, skipSignatureCheck = skipSignatureCheck, + blacklistPatterns = blacklistPatterns, ) AppListFragment.Source.UPDATES -> Updates( diff --git a/app/src/main/res/drawable/ic_block.xml b/app/src/main/res/drawable/ic_block.xml new file mode 100644 index 000000000..f8b28cc95 --- /dev/null +++ b/app/src/main/res/drawable/ic_block.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a937bdc53..c62048cf1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,4 +289,17 @@ Export buttons Import buttons Custom buttons imported + + App blacklist + Hides apps from all lists and search (excludes updates) + Add blacklist entry + Package ID pattern + App name pattern + Use * as wildcard + Delete entry? + Export blacklist + Import blacklist + Added to blacklist + Already in blacklist + Blacklist imported From 2513760849ec507fafaec4ef97f7827fafe9936e Mon Sep 17 00:00:00 2001 From: jeeneo Date: Sat, 28 Mar 2026 23:32:54 -0400 Subject: [PATCH 2/2] edit: make blacklist expandable, hidden by default --- .../components/BlacklistSettingItem.kt | 57 +++++++++++++++---- app/src/main/res/values/strings.xml | 1 + 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt b/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt index eb1e3364d..f00d1c4c9 100644 --- a/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt +++ b/app/src/main/kotlin/com/looker/droidify/compose/settings/components/BlacklistSettingItem.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu @@ -54,6 +56,7 @@ fun BlacklistSettingItem( var showEditor by remember { mutableStateOf(false) } var editingEntry by remember { mutableStateOf(null) } var showMenu by remember { mutableStateOf(false) } + var showBlacklist by remember { mutableStateOf(false) } Column( modifier = modifier @@ -121,21 +124,53 @@ fun BlacklistSettingItem( } } } - if (entries.isNotEmpty()) { - Spacer(modifier = Modifier.height(12.dp)) - entries.forEach { entry -> - BlacklistEntryItem( - entry = entry, - onEdit = { - editingEntry = entry - showEditor = true - }, - onDelete = { onRemoveEntry(entry.id) }, + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable( + onClick = { showBlacklist = !showBlacklist }, + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text( + text = stringResource(R.string.app_blacklist_showhide, entries.size), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Icon( + imageVector = if (showBlacklist) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) - Spacer(modifier = Modifier.height(8.dp)) + } + if (showBlacklist) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + entries.forEachIndexed { index, entry -> + BlacklistEntryItem( + entry = entry, + onEdit = { + editingEntry = entry + showEditor = true + }, + onDelete = { onRemoveEntry(entry.id) }, + ) + if (index < entries.lastIndex) { + Spacer(modifier = Modifier.height(6.dp)) + } + } + } } } + } if (showEditor) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c62048cf1..1e73b416d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,4 +302,5 @@ Added to blacklist Already in blacklist Blacklist imported + Show/hide list