From bd0b52d2731f4ccf300882751d146473ba4c520a Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:23:04 +0300 Subject: [PATCH 1/4] Handle disabled installed apps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 1 + .../anod/appwatcher/compose/IconsResources.kt | 11 +- .../anod/appwatcher/database/AppListTable.kt | 39 ++- .../anod/appwatcher/details/AppItemState.kt | 18 +- .../anod/appwatcher/details/DetailsPanel.kt | 53 ++-- .../appwatcher/details/DetailsViewModel.kt | 38 ++- .../installed/InstalledListViewModel.kt | 1 + .../installed/InstalledPagingSource.kt | 13 +- .../installed/PackageRemovedReceiver.kt | 32 +- .../anod/appwatcher/model/AppListFilter.kt | 55 ++-- .../com/anod/appwatcher/sync/UpdateCheck.kt | 49 +++- .../appwatcher/utils/PackageEnabledState.kt | 47 +++ .../com/anod/appwatcher/watchlist/Section.kt | 18 +- .../watchlist/SectionHeaderFactory.kt | 33 +-- .../appwatcher/watchlist/WatchListPage.kt | 24 +- .../watchlist/WatchListPagingSource.kt | 93 ++++-- .../watchlist/WatchListStateViewModel.kt | 20 ++ app/src/main/res/values-hu/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../database/AppListTableQueriesTest.kt | 8 +- .../database/AppListTableRoomTest.kt | 1 - .../appwatcher/details/AppItemStateTest.kt | 75 +++++ .../sync/UpdateCheckVersionRollbackTest.kt | 57 +++- .../utils/PackageEnabledStateTest.kt | 92 ++++++ .../appwatcher/watchlist/SectionItemTest.kt | 6 +- .../WatchListPagingSourceRoomTest.kt | 276 +++++++++++++++++- 27 files changed, 913 insertions(+), 153 deletions(-) create mode 100644 app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt create mode 100644 app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt create mode 100644 app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ef55acc..82f28bd0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,6 +120,7 @@ + diff --git a/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt b/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt index b15f61a8..3f503d39 100644 --- a/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt +++ b/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Label import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.PhonelinkOff import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked @@ -117,10 +118,14 @@ fun ShareIcon() { } @Composable -fun InstalledSignIcon(modifier: Modifier = Modifier, tint: Color = LocalContentColor.current) { +fun InstalledSignIcon( + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + enabled: Boolean = true +) { Icon( - imageVector = Icons.Default.Smartphone, - contentDescription = stringResource(id = R.string.installed), + imageVector = if (enabled) Icons.Default.Smartphone else Icons.Default.PhonelinkOff, + contentDescription = stringResource(id = if (enabled) R.string.installed else R.string.installed_disabled), modifier = modifier, tint = tint ) diff --git a/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt b/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt index d2fadee6..c8ee189d 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt @@ -30,6 +30,12 @@ data class AppListRowSnapshot( @ColumnInfo(name = BaseColumns._ID) val rowId: Int, + @ColumnInfo(name = AppListTable.Columns.PACKAGE_NAME) + val packageName: String, + + @ColumnInfo(name = AppListTable.Columns.VERSION_NUMBER) + val versionNumber: Int, + @ColumnInfo(name = AppListTable.Columns.STATUS) val status: Int, @@ -84,6 +90,12 @@ interface AppListTable { ) fun observePackagesList(): Flow> + @Query( + "SELECT ${BaseColumns._ID}, ${Columns.PACKAGE_NAME} FROM $TABLE WHERE " + + "${Columns.STATUS} = ${App.STATUS_UPDATED}" + ) + suspend fun loadUpdatedPackages(): List + @Query( "SELECT ${BaseColumns._ID}, ${Columns.PACKAGE_NAME} FROM $TABLE WHERE " + "CASE :includeDeleted WHEN 0 THEN ${Columns.STATUS} != ${App.STATUS_DELETED} ELSE ${Columns.STATUS} >= ${App.STATUS_NORMAL} END" @@ -140,6 +152,14 @@ interface AppListTable { ) suspend fun clearUpdateStatus(rowId: Int): Int + @Query( + "UPDATE $TABLE SET " + + "${Columns.STATUS} = ${App.STATUS_NORMAL}, " + + "${Columns.SYNC_TIMESTAMP} = 0 " + + "WHERE ${BaseColumns._ID} IN (:rowIds) AND ${Columns.STATUS} = ${App.STATUS_UPDATED}" + ) + suspend fun clearUpdateStatuses(rowIds: List): Int + @Query( "UPDATE $TABLE SET " + "${Columns.TITLE} = :title, " + @@ -227,12 +247,11 @@ interface AppListTable { suspend fun loadAppListRows( sortId: Int, - orderByRecentlyDiscovered: Boolean, tagId: Int?, titleFilter: String, table: AppListTable ): List { - val query = createAppsListRowsQuery(sortId, orderByRecentlyDiscovered, tagId, titleFilter) + val query = createAppsListRowsQuery(sortId, tagId, titleFilter) return table.loadRowSnapshots(SimpleSQLiteQuery(query.first, query.second)) } @@ -265,17 +284,16 @@ interface AppListTable { internal fun createAppsListRowsQuery( sortId: Int, - orderByRecentlyDiscovered: Boolean, tagId: Int?, titleFilter: String ): Pair> { val selection = createSelection(tagId, titleFilter) val sql = - "SELECT $TABLE.${BaseColumns._ID}, ${Columns.STATUS}, " + + "SELECT $TABLE.${BaseColumns._ID}, ${Columns.PACKAGE_NAME}, ${Columns.VERSION_NUMBER}, ${Columns.STATUS}, " + "CASE WHEN ${Columns.SYNC_TIMESTAMP} > $recentTime THEN 1 ELSE 0 END ${Columns.RECENT_FLAG} " + "FROM $TABLE " + "WHERE ${selection.first} " + - "ORDER BY ${createSortOrder(sortId, orderByRecentlyDiscovered)} " + "ORDER BY ${createSnapshotSortOrder(sortId)} " return Pair(sql, selection.second) } @@ -365,6 +383,15 @@ interface AppListTable { if (orderByRecentlyUpdated) { filter.add("CASE WHEN ${Columns.SYNC_TIMESTAMP} > $recentTime THEN 1 ELSE 0 END DESC") } + filter.addAll(createUserSortOrder(sortId)) + return filter.joinToString(", ") + } + + private fun createSnapshotSortOrder(sortId: Int): String = + createUserSortOrder(sortId).joinToString(", ") + + private fun createUserSortOrder(sortId: Int): List { + val filter = mutableListOf() when (sortId) { Preferences.SORT_NAME_DESC -> filter.add(Columns.TITLE + " COLLATE NOCASE DESC") Preferences.SORT_DATE_ASC -> filter.add(Columns.UPLOAD_TIMESTAMP + " ASC") @@ -372,7 +399,7 @@ interface AppListTable { else -> filter.add(Columns.TITLE + " COLLATE NOCASE ASC") } filter.add("$TABLE.${BaseColumns._ID} ASC") - return filter.joinToString(", ") + return filter } private fun createSelection(tagId: Int?, titleFilter: String): Pair> { diff --git a/app/src/main/java/com/anod/appwatcher/details/AppItemState.kt b/app/src/main/java/com/anod/appwatcher/details/AppItemState.kt index 7581e5c1..abd3ead6 100644 --- a/app/src/main/java/com/anod/appwatcher/details/AppItemState.kt +++ b/app/src/main/java/com/anod/appwatcher/details/AppItemState.kt @@ -30,28 +30,39 @@ fun rememberAppItemState( app: App, recentFlag: Boolean, packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean, textColor: Color = MaterialTheme.colorScheme.onSurface, primaryColor: Color = MaterialTheme.colorScheme.primary ): AppItemState { val context = LocalContext.current - return remember(app, recentFlag, packageInfo, textColor, primaryColor, context) { + return remember(app, recentFlag, packageInfo, isPackageEnabled, textColor, primaryColor, context) { calcAppItemState( - app, recentFlag, textColor, primaryColor, packageInfo, context + app, recentFlag, textColor, primaryColor, packageInfo, isPackageEnabled, context ) } } -private fun calcAppItemState( +internal fun calcAppItemState( app: App, recentFlag: Boolean, textColor: Color, primaryColor: Color, packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean, context: Context ): AppItemState { var color = textColor var installed = false val text = when { + packageInfo.isInstalled && !isPackageEnabled -> { + installed = true + context.getString( + R.string.installed_disabled_version, + packageInfo.versionName, + packageInfo.versionCode + ) + } + app.versionNumber == 0 -> { color = Amber800 context.getString(R.string.updates_not_available) @@ -89,6 +100,7 @@ private fun calcAppItemState( } val showRecent = when { + !isPackageEnabled -> false app.status == App.STATUS_UPDATED || recentFlag -> true else -> false } diff --git a/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt b/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt index b6693ba5..41fac291 100644 --- a/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt +++ b/app/src/main/java/com/anod/appwatcher/details/DetailsPanel.kt @@ -513,6 +513,7 @@ fun VersionDetails(screenState: DetailsState) { ) { if (screenState.packageInfo.isInstalled || screenState.isLocalApp) { InstalledSignIcon( + enabled = screenState.isPackageEnabled, modifier = Modifier .size(16.dp) .padding(end = 4.dp) @@ -521,7 +522,8 @@ fun VersionDetails(screenState: DetailsState) { val appItemState = rememberAppItemState( app = screenState.app, recentFlag = false, - packageInfo = screenState.packageInfo + packageInfo = screenState.packageInfo, + isPackageEnabled = screenState.isPackageEnabled ) Text( text = appItemState.text, @@ -624,7 +626,10 @@ private fun ChangelogList( items(changelogs.size) { i -> Column { if (i == dividerIndex) { - InstalledChangelogDivider(packageInfo = packageInfo) + InstalledChangelogDivider( + packageInfo = packageInfo, + isPackageEnabled = screenState.isPackageEnabled + ) } ChangelogItem( change = changelogs[i], @@ -634,7 +639,10 @@ private fun ChangelogList( } if (dividerIndex == changelogs.size) { item { - InstalledChangelogDivider(packageInfo = packageInfo) + InstalledChangelogDivider( + packageInfo = packageInfo, + isPackageEnabled = screenState.isPackageEnabled + ) } } } @@ -705,9 +713,13 @@ private fun ChangelogItemHeader(change: AppChange) { } @Composable -private fun InstalledChangelogDivider(packageInfo: InstalledApps.Info, modifier: Modifier = Modifier) { +private fun InstalledChangelogDivider( + packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean, + modifier: Modifier = Modifier +) { val versionText = stringResource(id = R.string.version_text, packageInfo.versionName, packageInfo.versionCode) - val installedText = stringResource(id = R.string.installed) + val installedText = stringResource(id = if (isPackageEnabled) R.string.installed else R.string.installed_disabled) Row( modifier = modifier .fillMaxWidth() @@ -717,6 +729,7 @@ private fun InstalledChangelogDivider(packageInfo: InstalledApps.Info, modifier: ) { ChangelogDividerLine(modifier = Modifier.weight(1f)) InstalledSignIcon( + enabled = isPackageEnabled, tint = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(start = 8.dp, end = 4.dp) @@ -1010,19 +1023,21 @@ private fun DetailsTopAppBar( ) if (screenState.packageInfo.isInstalled) { - DropdownMenuItem( - text = { - Text( - text = stringResource(id = R.string.open), - modifier = Modifier.padding(horizontal = 8.dp) - ) - }, - leadingIcon = { OpenAppIcon() }, - onClick = { - onEvent(DetailsEvent.Open) - dismiss() - } - ) + if (screenState.isPackageEnabled) { + DropdownMenuItem( + text = { + Text( + text = stringResource(id = R.string.open), + modifier = Modifier.padding(horizontal = 8.dp) + ) + }, + leadingIcon = { OpenAppIcon() }, + onClick = { + onEvent(DetailsEvent.Open) + dismiss() + } + ) + } DropdownMenuItem( text = { Text( @@ -1091,6 +1106,7 @@ private fun DetailsScreenPreview() { syncTime = 0 ), title = "Test title long app name", + isPackageEnabled = true, changelogState = ChangelogLoadState.Complete, changelogs = listOf( AppChange( @@ -1178,6 +1194,7 @@ private fun VersionInfoPreview() { syncTime = 0 ), title = "Test title long app name", + isPackageEnabled = true, changelogState = ChangelogLoadState.Complete, changelogs = listOf(), remoteVersionInfo = AppVersionInfo( diff --git a/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt b/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt index 1f2af31b..62083b4f 100644 --- a/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/details/DetailsViewModel.kt @@ -33,9 +33,11 @@ import com.anod.appwatcher.preferences.SelectedTheme import com.anod.appwatcher.tags.TagSnackbarAppInfo import com.anod.appwatcher.utils.AppIconLoader import com.anod.appwatcher.utils.BaseFlowViewModel +import com.anod.appwatcher.utils.PackageChangedReceiver import com.anod.appwatcher.utils.androidVersions import com.anod.appwatcher.utils.date.UploadDateParserCache import com.anod.appwatcher.utils.forPlayStore +import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.utils.prefs import finsky.api.DfeApi import finsky.api.Document @@ -53,6 +55,7 @@ import java.net.URLEncoder import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -99,6 +102,7 @@ data class DetailsState( val remoteVersionInfo: AppVersionInfo? = null, val remoteCallFinished: Boolean = false, val packageInfo: InstalledApps.Info = InstalledApps.Info(0, ""), + val isPackageEnabled: Boolean, val isSystemInDarkTheme: Boolean = false, val theme: SelectedTheme = SelectedTheme() ) { @@ -159,10 +163,12 @@ class DetailsViewModel(app: App, isSystemInDarkTheme: Boolean): BaseFlowViewMode private val uploadDateParserCache: UploadDateParserCache by inject() private val iconLoader: AppIconLoader by inject() private val packageManager: PackageManager by inject() + private val packageChangedReceiver: PackageChangedReceiver by inject() private val dfeApi: DfeApi by inject() val installedApps: InstalledApps = InstalledApps.PackageManager(packageManager) init { + val packageInfo = installedApps.packageInfo(app.packageName) viewState = DetailsState( appId = app.appId, rowId = app.rowId, @@ -171,7 +177,8 @@ class DetailsViewModel(app: App, isSystemInDarkTheme: Boolean): BaseFlowViewMode appIconState = if (app.iconUrl.isEmpty()) AppIconState.Default else AppIconState.Initial, title = app.title, isLocalApp = app.rowId == -1, - packageInfo = installedApps.packageInfo(app.packageName), + packageInfo = packageInfo, + isPackageEnabled = !packageInfo.isInstalled || packageManager.isPackageEnabled(app.packageName), isSystemInDarkTheme = isSystemInDarkTheme, theme = prefs.selectedTheme ) @@ -180,6 +187,21 @@ class DetailsViewModel(app: App, isSystemInDarkTheme: Boolean): BaseFlowViewMode loadAppIcon(app.iconUrl) } observeApp() + viewModelScope.launch { + packageChangedReceiver.observer + .filter { it.startsWith("${app.packageName}:") } + .collect { + refreshPackageState(app.packageName) + } + } + } + + private fun refreshPackageState(packageName: String) { + val packageInfo = installedApps.packageInfo(packageName) + viewState = viewState.copy( + packageInfo = packageInfo, + isPackageEnabled = !packageInfo.isInstalled || packageManager.isPackageEnabled(packageName) + ) } @OptIn(ExperimentalCoroutinesApi::class) @@ -273,13 +295,21 @@ class DetailsViewModel(app: App, isSystemInDarkTheme: Boolean): BaseFlowViewMode DetailsEvent.OnBackPressed -> emitAction(DetailsAction.Dismiss) DetailsEvent.Open -> { - val launchIntent = packageManager.getLaunchIntentForPackage(viewState.appId) - if (launchIntent != null) { + if (!viewState.isPackageEnabled) { emitAction( startActivityAction( - intent = launchIntent, + intent = Intent().forAppInfo(viewState.appId), ) ) + } else { + val launchIntent = packageManager.getLaunchIntentForPackage(viewState.appId) + if (launchIntent != null) { + emitAction( + startActivityAction( + intent = launchIntent, + ) + ) + } } } diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt index bd1bf5de..d5861c1a 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt @@ -84,6 +84,7 @@ class InstalledListViewModel(state: SavedStateHandle, showAction: Boolean, sortI viewModelScope.launch { packageChanged.observer.collect { packageChanged -> if (viewState.importStatus !is ImportStatus.Progress) { + installedApps.reset() viewState = viewState.copy(packageChanged = packageChanged) } } diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt index 0e580f51..6515a2db 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt @@ -7,13 +7,20 @@ import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppListItem import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.watchlist.FilterablePagingSource import com.anod.appwatcher.watchlist.SectionItem import info.anodsplace.applog.AppLog import info.anodsplace.framework.content.InstalledApps import info.anodsplace.ktx.dayStartAgoMillis -class InstalledPagingSource(private val changelogAdapter: ChangelogAdapter, private val packageManager: PackageManager, private val database: AppsDatabase, private val installedApps: InstalledApps,) : +class InstalledPagingSource( + private val changelogAdapter: ChangelogAdapter, + private val packageManager: PackageManager, + private val database: AppsDatabase, + private val installedApps: InstalledApps, + private val packageEnabled: (String) -> Boolean = { packageManager.isPackageEnabled(it) }, +) : FilterablePagingSource() { override var filterQuery: String = "" var sortId: Int = 0 @@ -47,6 +54,7 @@ class InstalledPagingSource(private val changelogAdapter: ChangelogAdapter, priv } .map { app -> val appChange = changelogAdapter.changelogs[app.appId] + val packageInfo = installedApps.packageInfo(app.packageName) SectionItem.OnDevice( appListItem = AppListItem( app = app, @@ -55,7 +63,8 @@ class InstalledPagingSource(private val changelogAdapter: ChangelogAdapter, priv recentFlag = false ), showSelection = selectionMode, - packageInfo = installedApps.packageInfo(app.packageName) + packageInfo = packageInfo, + isPackageEnabled = !packageInfo.isInstalled || packageEnabled(app.packageName) ) }.toList() diff --git a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt index 2887682d..cd04a8d6 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt @@ -4,8 +4,13 @@ package com.anod.appwatcher.installed import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.utils.PackageChangedReceiver import com.anod.appwatcher.utils.appScope +import com.anod.appwatcher.utils.clearDisabledUpdateStatuses +import com.anod.appwatcher.utils.isPackageEnabled +import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -16,21 +21,36 @@ class PackageRemovedReceiver : BroadcastReceiver(), KoinComponent { val action = intent?.action ?: return when (action) { Intent.ACTION_PACKAGE_FULLY_REMOVED -> { - notify(context, intent) + notify(intent, clearDisabledUpdates = false) } Intent.ACTION_PACKAGE_ADDED -> { - notify(context, intent) + notify(intent, clearDisabledUpdates = true) + } + Intent.ACTION_PACKAGE_CHANGED -> { + notify(intent, clearDisabledUpdates = true) } Intent.ACTION_PACKAGE_REPLACED -> { - notify(context, intent) + notify(intent, clearDisabledUpdates = true) } } } - private fun notify(context: Context?, intent: Intent?) { - val packageName = intent?.data?.schemeSpecificPart ?: "" + private fun notify(intent: Intent, clearDisabledUpdates: Boolean) { + val packageName = intent.data?.schemeSpecificPart ?: "" + val pendingResult = goAsync() appScope.launch { - get().emit(packageName + ":" + System.currentTimeMillis()) + try { + if (clearDisabledUpdates) { + val packageManager = get() + get().apps().clearDisabledUpdateStatuses( + installedApps = InstalledApps.PackageManager(packageManager), + packageEnabled = packageManager::isPackageEnabled + ) + } + get().emit(packageName + ":" + System.currentTimeMillis()) + } finally { + pendingResult.finish() + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/model/AppListFilter.kt b/app/src/main/java/com/anod/appwatcher/model/AppListFilter.kt index fe15bac4..7e6948ac 100644 --- a/app/src/main/java/com/anod/appwatcher/model/AppListFilter.kt +++ b/app/src/main/java/com/anod/appwatcher/model/AppListFilter.kt @@ -1,6 +1,5 @@ package com.anod.appwatcher.model -import com.anod.appwatcher.database.entities.AppListItem import info.anodsplace.framework.content.InstalledApps import info.anodsplace.ktx.hashCodeOf @@ -12,48 +11,58 @@ import info.anodsplace.ktx.hashCodeOf interface AppListFilter { val filterId: Int - fun filterRecord(item: AppListItem): Boolean + fun filterRecord( + versionCode: Int, + packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean + ): Boolean class All : AppListFilter { override val filterId = Filters.ALL - override fun filterRecord(item: AppListItem): Boolean = false + override fun filterRecord( + versionCode: Int, + packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean + ): Boolean = false override fun hashCode(): Int = hashCodeOf(filterId) override fun equals(other: Any?): Boolean = (other as? Installed)?.hashCode() == hashCode() } - class Installed(private val installedApps: InstalledApps) : AppListFilter { + class Installed : AppListFilter { override val filterId = Filters.INSTALLED - override fun filterRecord(item: AppListItem): Boolean { - val packageName = item.app.packageName - val installedInfo = installedApps.packageInfo(packageName) - return !installedInfo.isInstalled - } + override fun filterRecord( + versionCode: Int, + packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean + ): Boolean = !packageInfo.isInstalled override fun hashCode(): Int = hashCodeOf(filterId) override fun equals(other: Any?): Boolean = (other as? Installed)?.hashCode() == hashCode() } - class Uninstalled(private val installedApps: InstalledApps) : AppListFilter { + class Uninstalled : AppListFilter { override val filterId = Filters.UNINSTALLED - override fun filterRecord(item: AppListItem): Boolean { - val packageName = item.app.packageName - val installedInfo = installedApps.packageInfo(packageName) - return installedInfo.isInstalled - } + override fun filterRecord( + versionCode: Int, + packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean + ): Boolean = packageInfo.isInstalled override fun hashCode(): Int = hashCodeOf(filterId) override fun equals(other: Any?): Boolean = (other as? Installed)?.hashCode() == hashCode() } - class Updatable(private val installedApps: InstalledApps) : AppListFilter { + class Updatable : AppListFilter { override val filterId = Filters.UPDATABLE - override fun filterRecord(item: AppListItem): Boolean { - val packageName = item.app.packageName - val installedInfo = installedApps.packageInfo(packageName) - val versionCode = item.app.versionNumber - val updatable = installedInfo.isInstalled && installedInfo.isUpdatable(versionCode) - return !updatable - } + override fun filterRecord( + versionCode: Int, + packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean + ): Boolean = !( + packageInfo.isInstalled && + isPackageEnabled && + packageInfo.isUpdatable(versionCode) + ) override fun hashCode(): Int = hashCodeOf(filterId) override fun equals(other: Any?): Boolean = (other as? Installed)?.hashCode() == hashCode() diff --git a/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt b/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt index bee1ad43..2b64e625 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt @@ -28,9 +28,11 @@ import com.anod.appwatcher.database.entities.Schedule import com.anod.appwatcher.database.entities.preserveCachedMetadata import com.anod.appwatcher.database.entities.toApp import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import com.anod.appwatcher.utils.compareLettersAndDigits import com.anod.appwatcher.utils.date.UploadDateParserCache import com.anod.appwatcher.utils.extractUploadDate +import com.anod.appwatcher.utils.isPackageEnabled import finsky.api.BulkDocId import finsky.api.DfeApi import finsky.api.DfeServerError @@ -92,6 +94,7 @@ class UpdateCheck( val updatedApp: UpdatedApp?, val persistChangelog: Boolean, val installedVersion: Int, + val installedEnabled: Boolean, val decision: AppUpdateDecision ) @@ -99,6 +102,7 @@ class UpdateCheck( val syncId: Long, val packageName: String, val installedVersion: Int, + val installedEnabled: Boolean, val cachedVersion: Int, val updateRemoteVersion: Int?, val fullRemoteVersion: Int?, @@ -242,6 +246,16 @@ class UpdateCheck( syncId: Long, verboseDiagnostics: Boolean ): SyncResult { + val clearedDisabledUpdates = database.apps().clearDisabledUpdateStatuses( + installedApps = installedAppsProvider, + packageEnabled = packageManager::isPackageEnabled + ) + if (clearedDisabledUpdates > 0) { + AppLog.i( + "Cleared $clearedDisabledUpdates disabled app update statuses", + "UpdateCheck" + ) + } val sortId = preferences.sortIndex val apps = AppListTable.Queries.loadAppList(false, sortId, database.apps()) if (apps.isEmpty) { @@ -388,11 +402,13 @@ class UpdateCheck( } for (packageName in packageNames) { val localItem = localApps.getValue(packageName) + val installedInfo = installedAppsProvider.packageInfo(packageName) logSyncDiagnostic( diagnostic = AppSyncDiagnostic( syncId = syncId, packageName = packageName, - installedVersion = installedAppsProvider.packageInfo(packageName).versionCode, + installedVersion = installedInfo.versionCode, + installedEnabled = installedInfo.isInstalled && packageManager.isPackageEnabled(packageName), cachedVersion = localItem.app.versionNumber, updateRemoteVersion = null, fullRemoteVersion = null, @@ -476,6 +492,7 @@ class UpdateCheck( syncId = syncId, packageName = localItem.app.packageName, installedVersion = result.installedVersion, + installedEnabled = result.installedEnabled, cachedVersion = localItem.app.versionNumber, updateRemoteVersion = marketApp.appDetails.versionCode, fullRemoteVersion = fullDocument?.appDetails?.versionCode, @@ -586,6 +603,7 @@ class UpdateCheck( val values = ContentValues() val installedInfo = installedAppsProvider.packageInfo(appDetails.packageName) + val installedEnabled = installedInfo.isInstalled && packageManager.isPackageEnabled(appDetails.packageName) val unavailableAction = reconcileUnavailableUpdate(marketDoc, localApp, installedInfo, values) if (unavailableAction != UnavailableUpdateAction.NONE) { return AppUpdateResult( @@ -593,6 +611,7 @@ class UpdateCheck( updatedApp = null, persistChangelog = false, installedVersion = installedInfo.versionCode, + installedEnabled = installedEnabled, decision = if (unavailableAction == UnavailableUpdateAction.ROLL_BACK) { AppUpdateDecision.UNAVAILABLE_ROLLBACK } else { @@ -611,6 +630,7 @@ class UpdateCheck( updatedApp = null, persistChangelog = true, installedVersion = installedInfo.versionCode, + installedEnabled = installedEnabled, decision = AppUpdateDecision.REMOTE_ROLLBACK ) } @@ -620,7 +640,8 @@ class UpdateCheck( cachedVersion = localApp.versionNumber, installedVersion = installedInfo.versionCode, status = localApp.status, - lastUpdatesViewed = lastUpdatesViewed + lastUpdatesViewed = lastUpdatesViewed, + installedEnabled = installedEnabled ) if (decision == AppUpdateDecision.MARK_UPDATED) { AppLog.i( @@ -642,6 +663,7 @@ class UpdateCheck( updatedApp = UpdatedApp(newApp, recentChanges, installedInfo.versionCode, true), persistChangelog = true, installedVersion = installedInfo.versionCode, + installedEnabled = installedEnabled, decision = decision ) } @@ -651,6 +673,7 @@ class UpdateCheck( AppUpdateDecision.RESTORE_DEVICE_UPDATE -> values.put(AppListTable.Columns.STATUS, App.STATUS_UPDATED) AppUpdateDecision.CLEAR_INSTALLED_UPDATE, + AppUpdateDecision.CLEAR_DISABLED_UPDATE, AppUpdateDecision.CLEAR_VIEWED_UPDATE -> { values.put(AppListTable.Columns.STATUS, App.STATUS_NORMAL) values.put(AppListTable.Columns.SYNC_TIMESTAMP, 0L) @@ -667,6 +690,7 @@ class UpdateCheck( AppUpdateDecision.MARK_UPDATED, AppUpdateDecision.REFRESH_INSTALLED_CURRENT, AppUpdateDecision.KEEP_DEVICE_UPDATE, + AppUpdateDecision.DISABLED_INSTALLED, AppUpdateDecision.CURRENT -> { } } @@ -676,6 +700,7 @@ class UpdateCheck( updatedApp = updatedApp, persistChangelog = true, installedVersion = installedInfo.versionCode, + installedEnabled = installedEnabled, decision = decision ) } @@ -695,7 +720,8 @@ class UpdateCheck( val packageId = packageDiagnosticId(diagnostic.packageName) AppLog.i( "Sync app decision (syncId=${diagnostic.syncId}, packageId=$packageId, " + - "installed=${diagnostic.installedVersion}, cached=${diagnostic.cachedVersion}, " + + "installed=${diagnostic.installedVersion}, enabled=${diagnostic.installedEnabled}, " + + "cached=${diagnostic.cachedVersion}, " + "updateRemote=$updateRemoteVersion, " + "fullRemote=$fullRemoteVersion, availability=${diagnostic.availability}, " + "statusBefore=${diagnostic.statusBefore}, decision=${diagnostic.decision.value}, " + @@ -876,8 +902,10 @@ internal enum class AppUpdateDecision(val value: String) { RESTORE_DEVICE_UPDATE("restore-device-update"), KEEP_DEVICE_UPDATE("keep-device-update"), CLEAR_INSTALLED_UPDATE("clear-installed-update"), + CLEAR_DISABLED_UPDATE("clear-disabled-update"), CLEAR_VIEWED_UPDATE("clear-viewed-update"), KEEP_UPDATED("keep-updated"), + DISABLED_INSTALLED("disabled-installed"), CURRENT("current"); val isSteadyState: Boolean @@ -886,14 +914,16 @@ internal enum class AppUpdateDecision(val value: String) { this == UNAVAILABLE_SUPPRESSED || this == UNAVAILABLE_ROLLBACK || this == KEEP_DEVICE_UPDATE || - this == KEEP_UPDATED + this == KEEP_UPDATED || + this == DISABLED_INSTALLED } internal enum class SyncDecisionSignal(val value: String) { NONE("none"), INVALID_MARKED_NOT_NEWER_THAN_INSTALLED("invalid-marked-not-newer-than-installed"), DEVICE_UPDATE_RESTORED("device-update-restored"), - INSTALLED_UPDATE_CLEARED("installed-update-cleared") + INSTALLED_UPDATE_CLEARED("installed-update-cleared"), + DISABLED_UPDATE_CLEARED("disabled-update-cleared") } internal fun detectSyncDecisionSignal( @@ -907,6 +937,8 @@ internal fun detectSyncDecisionSignal( SyncDecisionSignal.DEVICE_UPDATE_RESTORED decision == AppUpdateDecision.CLEAR_INSTALLED_UPDATE -> SyncDecisionSignal.INSTALLED_UPDATE_CLEARED + decision == AppUpdateDecision.CLEAR_DISABLED_UPDATE -> + SyncDecisionSignal.DISABLED_UPDATE_CLEARED else -> SyncDecisionSignal.NONE } @@ -915,11 +947,16 @@ internal fun selectAppUpdateDecision( cachedVersion: Int, installedVersion: Int, status: Int, - lastUpdatesViewed: Boolean + lastUpdatesViewed: Boolean, + installedEnabled: Boolean ): AppUpdateDecision { require(remoteVersion >= cachedVersion) val isInstalled = installedVersion > 0 return when { + isInstalled && !installedEnabled && status == App.STATUS_UPDATED -> + AppUpdateDecision.CLEAR_DISABLED_UPDATE + isInstalled && !installedEnabled -> + AppUpdateDecision.DISABLED_INSTALLED isInstalled && remoteVersion <= installedVersion && status == App.STATUS_UPDATED -> AppUpdateDecision.CLEAR_INSTALLED_UPDATE isInstalled && remoteVersion <= installedVersion && remoteVersion > cachedVersion -> diff --git a/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt new file mode 100644 index 00000000..54e40fcf --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt @@ -0,0 +1,47 @@ +package com.anod.appwatcher.utils + +import android.content.pm.PackageManager +import com.anod.appwatcher.database.AppListTable +import info.anodsplace.framework.content.InstalledApps +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +fun PackageManager.isPackageEnabled(packageName: String): Boolean { + val applicationInfo = try { + getApplicationInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + return false + } + val enabledSetting = try { + getApplicationEnabledSetting(packageName) + } catch (_: IllegalArgumentException) { + return false + } + return isPackageEnabled(applicationInfo.enabled, enabledSetting) +} + +internal fun isPackageEnabled(manifestEnabled: Boolean, enabledSetting: Int): Boolean = when (enabledSetting) { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> manifestEnabled + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> false + else -> manifestEnabled +} + +internal suspend fun AppListTable.clearDisabledUpdateStatuses( + installedApps: InstalledApps, + packageEnabled: (String) -> Boolean +): Int = withContext(Dispatchers.IO) { + val disabledRowIds = loadUpdatedPackages().mapNotNull { row -> + val packageInfo = installedApps.packageInfo(row.packageName) + row.rowId.takeIf { + packageInfo.isInstalled && !packageEnabled(row.packageName) + } + } + var cleared = 0 + for (rowIds in disabledRowIds.chunked(998)) { + cleared += clearUpdateStatuses(rowIds) + } + cleared +} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/Section.kt b/app/src/main/java/com/anod/appwatcher/watchlist/Section.kt index b6d2443d..c4c94d66 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/Section.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/Section.kt @@ -61,12 +61,17 @@ sealed interface SectionItem { } @Immutable - class App(val appListItem: AppListItem, val isLocal: Boolean, val packageInfo: InstalledApps.Info) : SectionItem { + class App( + val appListItem: AppListItem, + val isLocal: Boolean, + val packageInfo: InstalledApps.Info, + val isPackageEnabled: Boolean + ) : SectionItem { override val sectionKey = "app-${appListItem.app.rowId}-${appListItem.app.packageName}" override val contentType = "App" val changesHtml: String = appListItem.cleanChangeHtml() - override fun hashCode(): Int = hashCodeOf("SectionItem.App", appListItem, isLocal, packageInfo) + override fun hashCode(): Int = hashCodeOf("SectionItem.App", appListItem, isLocal, packageInfo, isPackageEnabled) override fun equals(other: Any?): Boolean { val item = other as? App ?: return false @@ -75,12 +80,17 @@ sealed interface SectionItem { } @Immutable - class OnDevice(val appListItem: AppListItem, var showSelection: Boolean, val packageInfo: InstalledApps.Info) : SectionItem { + class OnDevice( + val appListItem: AppListItem, + var showSelection: Boolean, + val packageInfo: InstalledApps.Info, + val isPackageEnabled: Boolean + ) : SectionItem { override val sectionKey = "ondevice-${appListItem.app.packageName}" override val contentType = "OnDevice" val changesHtml: String = appListItem.cleanChangeHtml() - override fun hashCode(): Int = hashCodeOf("SectionItem.OnDevice", appListItem, showSelection, packageInfo) + override fun hashCode(): Int = hashCodeOf("SectionItem.OnDevice", appListItem, showSelection, packageInfo, isPackageEnabled) override fun equals(other: Any?): Boolean { val item = other as? OnDevice ?: return false diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/SectionHeaderFactory.kt b/app/src/main/java/com/anod/appwatcher/watchlist/SectionHeaderFactory.kt index 9f30e446..4ee9f0e2 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/SectionHeaderFactory.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/SectionHeaderFactory.kt @@ -17,6 +17,12 @@ interface SectionHeaderFactory { class DefaultSectionHeaderFactory(private var showRecentlyDiscovered: Boolean) : SectionHeaderFactory { + private val SectionItem.App.hasUpdate: Boolean + get() = isPackageEnabled && appListItem.app.status == App.STATUS_UPDATED + + private val SectionItem.App.isRecentlyDiscovered: Boolean + get() = isPackageEnabled && appListItem.recentFlag + override fun insertSeparator(before: SectionItem?, after: SectionItem?): SectionItem.Header? { if (after == null) { // we're at the end of the list @@ -28,12 +34,10 @@ class DefaultSectionHeaderFactory(private var showRecentlyDiscovered: Boolean) : is SectionItem.Recent -> return SectionItem.Header(SectionHeader.RecentlyInstalled) is SectionItem.OnDevice -> return SectionItem.Header(SectionHeader.OnDevice) is SectionItem.App -> { - val appListItem = after.appListItem - val status = appListItem.app.status - if (status == App.STATUS_UPDATED) { + if (after.hasUpdate) { return SectionItem.Header(SectionHeader.New) } - if (showRecentlyDiscovered && appListItem.recentFlag) { + if (showRecentlyDiscovered && after.isRecentlyDiscovered) { return SectionItem.Header(SectionHeader.RecentlyDiscovered) } return SectionItem.Header(SectionHeader.Watching) @@ -49,12 +53,10 @@ class DefaultSectionHeaderFactory(private var showRecentlyDiscovered: Boolean) : when (after) { is SectionItem.OnDevice -> return SectionItem.Header(SectionHeader.OnDevice) is SectionItem.App -> { - val appListItem = after.appListItem - val status = appListItem.app.status - if (status == App.STATUS_UPDATED) { + if (after.hasUpdate) { return SectionItem.Header(SectionHeader.New) } - if (showRecentlyDiscovered && appListItem.recentFlag) { + if (showRecentlyDiscovered && after.isRecentlyDiscovered) { return SectionItem.Header(SectionHeader.RecentlyDiscovered) } return SectionItem.Header(SectionHeader.Watching) @@ -69,22 +71,17 @@ class DefaultSectionHeaderFactory(private var showRecentlyDiscovered: Boolean) : when (after) { is SectionItem.OnDevice -> return SectionItem.Header(SectionHeader.OnDevice) is SectionItem.App -> { - val beforeItem = before.appListItem - val afterItem = after.appListItem - if ( - beforeItem.app.status == App.STATUS_UPDATED && - afterItem.app.status == App.STATUS_NORMAL - ) { - if (showRecentlyDiscovered && afterItem.recentFlag) { + if (before.hasUpdate && !after.hasUpdate) { + if (showRecentlyDiscovered && after.isRecentlyDiscovered) { return SectionItem.Header(SectionHeader.RecentlyDiscovered) } return SectionItem.Header(SectionHeader.Watching) } else if ( showRecentlyDiscovered && - beforeItem.app.status == App.STATUS_NORMAL && - afterItem.app.status == App.STATUS_NORMAL + !before.hasUpdate && + !after.hasUpdate ) { - if (beforeItem.recentFlag && !afterItem.recentFlag) { + if (before.isRecentlyDiscovered && !after.isRecentlyDiscovered) { return SectionItem.Header(SectionHeader.Watching) } } diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPage.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPage.kt index dcff9625..1a8fa34d 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPage.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPage.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -75,6 +74,7 @@ import coil3.compose.AsyncImage import com.anod.appwatcher.R import com.anod.appwatcher.compose.AppIconImage import com.anod.appwatcher.compose.AppTheme +import com.anod.appwatcher.compose.InstalledSignIcon import com.anod.appwatcher.compose.WatchedIcon import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppListItem @@ -219,6 +219,7 @@ fun WatchListSectionItem( selection = selection, selectionMode = selectionMode, packageInfo = item.packageInfo, + isPackageEnabled = item.isPackageEnabled, appIconLoader = appIconLoader ) @@ -232,6 +233,7 @@ fun WatchListSectionItem( selection = selection, selectionMode = selectionMode, packageInfo = item.packageInfo, + isPackageEnabled = item.isPackageEnabled, appIconLoader = appIconLoader ) @@ -317,6 +319,7 @@ private fun AppItem( onClick: (() -> Unit), onLongClick: (() -> Unit), packageInfo: InstalledApps.Info, + isPackageEnabled: Boolean, modifier: Modifier = Modifier, selection: SelectionState = SelectionState(), selectionMode: Boolean = false, @@ -327,7 +330,7 @@ private fun AppItem( derivedStateOf { getPackageSelection(app.packageName, selectionMode, selection) } } val appItemState = rememberAppItemState( - app, item.recentFlag, packageInfo + app, item.recentFlag, packageInfo, isPackageEnabled ) Box(modifier = modifier) { @@ -383,9 +386,8 @@ private fun AppItem( ) } if (appItemState.installed || isLocalApp) { - Icon( - imageVector = Icons.Default.Smartphone, - contentDescription = stringResource(id = R.string.installed), + InstalledSignIcon( + enabled = isPackageEnabled, modifier = Modifier .size(16.dp) .padding(end = 4.dp) @@ -729,7 +731,8 @@ private fun WatchListPreview() { recentFlag = true ), isLocal = false, - packageInfo = InstalledApps.Info(0, "") + packageInfo = InstalledApps.Info(0, ""), + isPackageEnabled = true ), SectionItem.App( AppListItem( app = App( @@ -755,7 +758,8 @@ private fun WatchListPreview() { recentFlag = true ), isLocal = true, - packageInfo = InstalledApps.Info(0, "") + packageInfo = InstalledApps.Info(0, ""), + isPackageEnabled = true ), SectionItem.App( AppListItem( app = App( @@ -784,7 +788,8 @@ private fun WatchListPreview() { packageInfo = InstalledApps.Info( versionName = "very long long version name consectetur adipiscing elit", versionCode = 11223300 - ) + ), + isPackageEnabled = true ), SectionItem.App( AppListItem( app = App( @@ -810,7 +815,8 @@ private fun WatchListPreview() { recentFlag = true ), isLocal = false, - packageInfo = InstalledApps.Info(versionName = "version name", versionCode = 11223300) + packageInfo = InstalledApps.Info(versionName = "version name", versionCode = 11223300), + isPackageEnabled = true ) ) val selectionState = SelectionState() diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt index f1119290..88664b24 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt @@ -7,12 +7,14 @@ import androidx.paging.PagingState import com.anod.appwatcher.database.AppListRowSnapshot import com.anod.appwatcher.database.AppListTable import com.anod.appwatcher.database.AppsDatabase +import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppListItem import com.anod.appwatcher.database.entities.packageToApp import com.anod.appwatcher.installed.InstalledTaskWorker import com.anod.appwatcher.model.AppListFilter import com.anod.appwatcher.model.Filters import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.isPackageEnabled import info.anodsplace.applog.AppLog import info.anodsplace.framework.content.InstalledApps import kotlin.math.max @@ -25,6 +27,7 @@ class WatchListPagingSource( private val packageManager: PackageManager, private val database: AppsDatabase, private val installedApps: InstalledApps, + private val packageEnabled: (String) -> Boolean = { packageManager.isPackageEnabled(it) }, ) : FilterablePagingSource() { override var filterQuery: String = "" set(value) { @@ -43,16 +46,24 @@ class WatchListPagingSource( private data class AppListSnapshot( val filterQuery: String, - val rows: List, + val rows: List, + ) + + private data class AppListSnapshotRow( + val databaseRow: AppListRowSnapshot, + val packageInfo: InstalledApps.Info, + val isPackageEnabled: Boolean, + val sectionRank: Int, + val sortPosition: Int, ) @Immutable data class Config(val filterId: Int, val tagId: Int?, val showRecentlyDiscovered: Boolean, val showOnDevice: Boolean, val showRecentlyInstalled: Boolean,) private fun createFilter(filterId: Int): AppListFilter = when (filterId) { - Filters.INSTALLED -> AppListFilter.Installed(installedApps) - Filters.UNINSTALLED -> AppListFilter.Uninstalled(installedApps) - Filters.UPDATABLE -> AppListFilter.Updatable(installedApps) + Filters.INSTALLED -> AppListFilter.Installed() + Filters.UNINSTALLED -> AppListFilter.Uninstalled() + Filters.UPDATABLE -> AppListFilter.Updatable() else -> AppListFilter.All() } @@ -77,26 +88,35 @@ class WatchListPagingSource( } else { snapshot.rows.subList(offset, minOf(snapshot.rows.size, offset + limit)) } - val pageRowsById = pageRows.associateBy { it.rowId } - val data = AppListTable.Queries.loadAppList(pageRows.map { it.rowId }, database.apps()) - .map { item -> + val pageRowsById = pageRows.associateBy { it.databaseRow.rowId } + val data = AppListTable.Queries.loadAppList(pageRows.map { it.databaseRow.rowId }, database.apps()) + .mapNotNull { item -> val snapshotRow = pageRowsById.getValue(item.app.rowId) - item.copy( - app = item.app.copy(status = snapshotRow.status), - recentFlag = snapshotRow.recentFlag, + if ( + item.app.packageName != snapshotRow.databaseRow.packageName || + item.app.versionNumber != snapshotRow.databaseRow.versionNumber + ) { + return@mapNotNull null + } + Pair( + item.copy( + app = item.app.copy(status = snapshotRow.databaseRow.status), + recentFlag = snapshotRow.databaseRow.recentFlag, + ), + snapshotRow ) } - val filtered = data.filter { !itemFilter.filterRecord(it) } var totalItems = countTotalItems( snapshotSize = snapshot.rows.size, hasMissingSnapshotRows = data.size < pageRows.size, ) - items.addAll(filtered.map { + items.addAll(data.map { (item, snapshotRow) -> SectionItem.App( - appListItem = it, + appListItem = item, isLocal = false, - packageInfo = installedApps.packageInfo(it.app.packageName) + packageInfo = snapshotRow.packageInfo, + isPackageEnabled = snapshotRow.isPackageEnabled ) }) @@ -156,15 +176,35 @@ class WatchListPagingSource( if (lockedSnapshot?.filterQuery == lockedFilterQuery) { lockedSnapshot } else { + val databaseRows = AppListTable.Queries.loadAppListRows( + sortId, + config.tagId, + lockedFilterQuery, + database.apps() + ) + val rows = databaseRows + .mapIndexed { index, row -> + val packageInfo = installedApps.packageInfo(row.packageName) + val isPackageEnabled = !packageInfo.isInstalled || packageEnabled(row.packageName) + AppListSnapshotRow( + databaseRow = row, + packageInfo = packageInfo, + isPackageEnabled = isPackageEnabled, + sectionRank = sectionRank(row, isPackageEnabled), + sortPosition = index, + ) + } + .filterNot { row -> + itemFilter.filterRecord( + versionCode = row.databaseRow.versionNumber, + packageInfo = row.packageInfo, + isPackageEnabled = row.isPackageEnabled + ) + } + .sortedWith(compareBy(AppListSnapshotRow::sectionRank, AppListSnapshotRow::sortPosition)) AppListSnapshot( filterQuery = lockedFilterQuery, - rows = AppListTable.Queries.loadAppListRows( - sortId, - config.showRecentlyDiscovered, - config.tagId, - lockedFilterQuery, - database.apps() - ), + rows = rows, ).also { appListSnapshot = it } @@ -172,6 +212,13 @@ class WatchListPagingSource( } } + private fun sectionRank(row: AppListRowSnapshot, isPackageEnabled: Boolean): Int = + when { + isPackageEnabled && row.status == App.STATUS_UPDATED -> 0 + isPackageEnabled && config.showRecentlyDiscovered && row.recentFlag -> 1 + else -> 2 + } + private suspend fun loadOnDeviceItems(titleFilter: String): List { val installed = InstalledTaskWorker(packageManager, sortId, titleFilter).run() val allInstalledPackageNames = installed.map { it.pkg.name } @@ -182,10 +229,12 @@ class WatchListPagingSource( .map { packageManager.packageToApp(-1, it) } .map { app -> AppListItem(app, "", noNewDetails = false, recentFlag = false) } .map { item -> + val packageInfo = installedApps.packageInfo(item.app.packageName) SectionItem.OnDevice( appListItem = item, showSelection = false, - packageInfo = installedApps.packageInfo(item.app.packageName) + packageInfo = packageInfo, + isPackageEnabled = !packageInfo.isInstalled || packageEnabled(item.app.packageName) ) }.toList() } diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt index c6844e8c..59447945 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt @@ -32,9 +32,11 @@ import com.anod.appwatcher.utils.BaseFlowViewModel import com.anod.appwatcher.utils.PackageChangedReceiver import com.anod.appwatcher.utils.SyncProgress import com.anod.appwatcher.utils.appScope +import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import com.anod.appwatcher.utils.color.MaterialColors import com.anod.appwatcher.utils.forMyApps import com.anod.appwatcher.utils.getInt +import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.utils.networkConnection import com.anod.appwatcher.utils.prefs import com.anod.appwatcher.utils.syncProgressFlow @@ -212,6 +214,17 @@ class WatchListStateViewModel( AppLog.d("Initial state: viewState") + viewModelScope.launch { + val clearedDisabledUpdates = db.apps().clearDisabledUpdateStatuses( + installedApps = installedApps, + packageEnabled = packageManager::isPackageEnabled + ) + if (clearedDisabledUpdates > 0) { + invalidatePagingSources() + viewState = viewState.copy(dbAppsChange = viewState.dbAppsChange + 1) + } + } + viewModelScope.launch { syncProgressFlow(application).collect { handleEvent(WatchListEvent.UpdateSyncProgress(syncProgress = it)) @@ -264,6 +277,13 @@ class WatchListStateViewModel( } } + viewModelScope.launch { + packageChangedReceiver.observer.collect { + installedApps.reset() + invalidatePagingSources() + } + } + viewModelScope.launch { viewStates.map { "${it.refreshRequest}-${it.dbAppsChange}-${it.listConfigChange}" } .combine(packageChangedReceiver.observer.onStart { emit("") }) { viewStateChange, packageName -> "$viewStateChange-$packageName" } diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 4716f8dc..a37e653f 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -38,6 +38,8 @@ \"%s\" hozzáadva Értékelés Telepítve + Telepítve, de letiltva + Telepítve, de letiltva · %1$s (%2$d) Telepítettek importálása A változásokról nem adtak meg információt. %1$s frissítve diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6d792348..092576ea 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -35,6 +35,8 @@ "%s\" добавлен в список для наблюдения Оценить в маркете Установленно + Установлено, но отключено + Установлено, но отключено · %1$s (%2$d) Нет информации о последних изменениях. Последнее обновление: %1$s Файлы не найдены diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 099db4ec..4f6609d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,8 @@ \"%s\" was added Rate this app Installed + Installed but disabled + Installed but disabled · %1$s (%2$d) Import installed No information about recent changes was provided. Updated %1$s diff --git a/app/src/test/java/com/anod/appwatcher/database/AppListTableQueriesTest.kt b/app/src/test/java/com/anod/appwatcher/database/AppListTableQueriesTest.kt index 1845096d..a5d0361c 100644 --- a/app/src/test/java/com/anod/appwatcher/database/AppListTableQueriesTest.kt +++ b/app/src/test/java/com/anod/appwatcher/database/AppListTableQueriesTest.kt @@ -43,17 +43,17 @@ class AppListTableQueriesTest { } @Test - fun `row snapshot query can order by recently discovered`() { + fun `row snapshot query preserves user sort for effective section grouping`() { val (sql, args) = AppListTable.Queries.createAppsListRowsQuery( sortId = Preferences.SORT_NAME_ASC, - orderByRecentlyDiscovered = true, tagId = null, titleFilter = "" ) assertTrue(sql.contains("CASE WHEN sync_version >")) - assertTrue(sql.contains("THEN 1 ELSE 0 END DESC")) - assertFalse(sql.contains("recent_flag DESC")) + assertTrue(sql.contains("ORDER BY title COLLATE NOCASE ASC, app_list._id ASC")) + assertFalse(sql.contains("status DESC")) + assertFalse(sql.contains("THEN 1 ELSE 0 END DESC")) assertEquals(listOf(App.STATUS_DELETED.toString()), args.toList()) } diff --git a/app/src/test/java/com/anod/appwatcher/database/AppListTableRoomTest.kt b/app/src/test/java/com/anod/appwatcher/database/AppListTableRoomTest.kt index a11a29c7..645da247 100644 --- a/app/src/test/java/com/anod/appwatcher/database/AppListTableRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/database/AppListTableRoomTest.kt @@ -248,7 +248,6 @@ class AppListTableRoomTest { private suspend fun loadIds(tagId: Int?, titleFilter: String): List { val rows = AppListTable.Queries.loadAppListRows( sortId = Preferences.SORT_NAME_ASC, - orderByRecentlyDiscovered = false, tagId = tagId, titleFilter = titleFilter, table = db.apps() diff --git a/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt b/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt new file mode 100644 index 00000000..8304cd60 --- /dev/null +++ b/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt @@ -0,0 +1,75 @@ +package com.anod.appwatcher.details + +import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.test.core.app.ApplicationProvider +import com.anod.appwatcher.R +import com.anod.appwatcher.database.entities.App +import com.anod.appwatcher.database.entities.Price +import info.anodsplace.framework.content.InstalledApps +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AppItemStateTest { + + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun disabledInstalledAppDoesNotShowAvailableUpgrade() { + val state = calcAppItemState( + app = app(versionNumber = 200, status = App.STATUS_UPDATED), + recentFlag = true, + textColor = Color.Black, + primaryColor = Color.Blue, + packageInfo = InstalledApps.Info(versionCode = 100, versionName = "1.0"), + isPackageEnabled = false, + context = context + ) + + assertTrue(state.installed) + assertFalse(state.showRecent) + assertEquals( + context.getString(R.string.installed_disabled_version, "1.0", 100), + state.text + ) + } + + @Test + fun enabledInstalledAppShowsAvailableUpgrade() { + val state = calcAppItemState( + app = app(versionNumber = 200), + recentFlag = false, + textColor = Color.Black, + primaryColor = Color.Blue, + packageInfo = InstalledApps.Info(versionCode = 100, versionName = "1.0"), + isPackageEnabled = true, + context = context + ) + + assertTrue(state.installed) + assertTrue(state.text.contains("200")) + } + + private fun app(versionNumber: Int, status: Int = App.STATUS_NORMAL) = App( + rowId = 1, + appId = "com.example.app", + packageName = "com.example.app", + versionNumber = versionNumber, + versionName = "2.0", + title = "Example", + creator = "Example", + iconUrl = "", + status = status, + uploadDate = "", + price = Price("", "", 0), + detailsUrl = null, + uploadTime = 0, + appType = "", + syncTime = 0 + ) +} \ No newline at end of file diff --git a/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt b/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt index fe54e594..1b24a5ce 100644 --- a/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt +++ b/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt @@ -59,7 +59,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 100, installedVersion = 101, status = App.STATUS_NORMAL, - lastUpdatesViewed = false + lastUpdatesViewed = false, + installedEnabled = true ) ) } @@ -73,7 +74,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 100, status = App.STATUS_NORMAL, - lastUpdatesViewed = true + lastUpdatesViewed = true, + installedEnabled = true ) ) } @@ -87,7 +89,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 100, status = App.STATUS_UPDATED, - lastUpdatesViewed = true + lastUpdatesViewed = true, + installedEnabled = true ) ) } @@ -101,7 +104,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 101, status = App.STATUS_UPDATED, - lastUpdatesViewed = false + lastUpdatesViewed = false, + installedEnabled = true ) ) } @@ -115,7 +119,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 0, status = App.STATUS_UPDATED, - lastUpdatesViewed = true + lastUpdatesViewed = true, + installedEnabled = false ) ) } @@ -129,7 +134,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 0, status = App.STATUS_UPDATED, - lastUpdatesViewed = false + lastUpdatesViewed = false, + installedEnabled = false ) ) } @@ -143,7 +149,38 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 101, status = App.STATUS_NORMAL, - lastUpdatesViewed = true + lastUpdatesViewed = true, + installedEnabled = true + ) + ) + } + + @Test + fun disabledInstalledAppDoesNotMarkNewVersionAsUpdate() { + assertEquals( + AppUpdateDecision.DISABLED_INSTALLED, + selectAppUpdateDecision( + remoteVersion = 101, + cachedVersion = 100, + installedVersion = 100, + status = App.STATUS_NORMAL, + lastUpdatesViewed = false, + installedEnabled = false + ) + ) + } + + @Test + fun disabledInstalledAppClearsExistingUpdate() { + assertEquals( + AppUpdateDecision.CLEAR_DISABLED_UPDATE, + selectAppUpdateDecision( + remoteVersion = 101, + cachedVersion = 101, + installedVersion = 100, + status = App.STATUS_UPDATED, + lastUpdatesViewed = false, + installedEnabled = false ) ) } @@ -157,7 +194,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 100, installedVersion = 100, status = App.STATUS_NORMAL, - lastUpdatesViewed = false + lastUpdatesViewed = false, + installedEnabled = true ) ) } @@ -171,7 +209,8 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 100, installedVersion = 0, status = App.STATUS_NORMAL, - lastUpdatesViewed = false + lastUpdatesViewed = false, + installedEnabled = false ) ) } diff --git a/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt b/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt new file mode 100644 index 00000000..5b4de87f --- /dev/null +++ b/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt @@ -0,0 +1,92 @@ +package com.anod.appwatcher.utils + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class PackageEnabledStateTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun defaultStateUsesManifestState() { + assertTrue(isPackageEnabled(true, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)) + assertFalse(isPackageEnabled(false, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)) + } + + @Test + fun explicitEnabledStateOverridesManifestState() { + assertTrue(isPackageEnabled(false, PackageManager.COMPONENT_ENABLED_STATE_ENABLED)) + } + + @Test + fun disabledStatesAreNotEnabled() { + assertFalse(isPackageEnabled(true, PackageManager.COMPONENT_ENABLED_STATE_DISABLED)) + assertFalse(isPackageEnabled(true, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER)) + assertFalse(isPackageEnabled(true, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED)) + } + + @Test + fun unknownStateUsesResolvedApplicationState() { + assertTrue(isPackageEnabled(true, Int.MAX_VALUE)) + assertFalse(isPackageEnabled(false, Int.MAX_VALUE)) + } + + @Test + fun packageManagerReadsEnabledSetting() { + val packageName = "enabled.state.test" + installPackage(packageName, manifestEnabled = true) + + assertTrue(context.packageManager.isPackageEnabled(packageName)) + + context.packageManager.setApplicationEnabledSetting( + packageName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED, + PackageManager.DONT_KILL_APP + ) + assertFalse(context.packageManager.isPackageEnabled(packageName)) + + context.packageManager.setApplicationEnabledSetting( + packageName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + assertTrue(context.packageManager.isPackageEnabled(packageName)) + } + + @Test + fun packageManagerUsesManifestStateByDefault() { + val packageName = "manifest.disabled.test" + installPackage(packageName, manifestEnabled = false) + + assertFalse(context.packageManager.isPackageEnabled(packageName)) + } + + private fun installPackage(packageName: String, manifestEnabled: Boolean) { + shadowOf(context.packageManager).installPackage( + PackageInfo().apply { + this.packageName = packageName + applicationInfo = ApplicationInfo().apply { + this.packageName = packageName + enabled = manifestEnabled + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/anod/appwatcher/watchlist/SectionItemTest.kt b/app/src/test/java/com/anod/appwatcher/watchlist/SectionItemTest.kt index 2ac5a22e..6b04d4ec 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/SectionItemTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/SectionItemTest.kt @@ -134,7 +134,8 @@ class SectionItemTest { versionNumber = versionNumber ), isLocal = false, - packageInfo = packageInfo + packageInfo = packageInfo, + isPackageEnabled = true ) private fun onDeviceSectionItem( @@ -145,7 +146,8 @@ class SectionItemTest { ): SectionItem.OnDevice = SectionItem.OnDevice( appListItem = appListItem(rowId = -1, packageName = packageName, title = title), showSelection = showSelection, - packageInfo = packageInfo + packageInfo = packageInfo, + isPackageEnabled = true ) private fun appListItem( diff --git a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt index 4e1e97cb..9f96470f 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt @@ -12,6 +12,7 @@ import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.Price import com.anod.appwatcher.model.Filters import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import info.anodsplace.framework.content.InstalledApps import info.anodsplace.notification.NotificationManager import kotlinx.coroutines.CoroutineScope @@ -68,6 +69,118 @@ class WatchListPagingSourceRoomTest { assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, page.itemsAfter) } + @Test + fun disabledInstalledAppIsNotShownAsUpdatable() = runBlocking { + insertApp( + appId = "disabled", + packageName = "disabled.watched", + title = "Z Disabled Watched", + versionNumber = 2, + status = App.STATUS_UPDATED + ) + insertApp( + appId = "enabled", + packageName = "enabled.watched", + title = "M Enabled Watched", + versionNumber = 2, + status = App.STATUS_UPDATED + ) + insertApp( + appId = "normal", + packageName = "normal.watched", + title = "A Normal Watched" + ) + + val allResult = createPagingSource( + showOnDevice = false, + packageEnabled = { it != "disabled.watched" } + ).load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)) + val appItems = (allResult as PagingSource.LoadResult.Page) + .data + .filterIsInstance() + val disabledItem = appItems.single { it.appListItem.app.packageName == "disabled.watched" } + + assertFalse(disabledItem.isPackageEnabled) + assertEquals( + listOf("enabled.watched", "normal.watched", "disabled.watched"), + appItems.map { it.appListItem.app.packageName } + ) + val sectionHeaderFactory = DefaultSectionHeaderFactory(showRecentlyDiscovered = false) + val headers = appItems.mapIndexedNotNull { index, item -> + sectionHeaderFactory.insertSeparator(appItems.getOrNull(index - 1), item) + } + assertEquals( + listOf(SectionHeader.New, SectionHeader.Watching), + headers.map { it.type } + ) + assertEquals(headers.size, headers.distinctBy { it.sectionKey }.size) + assertEquals( + SectionHeader.Watching, + DefaultSectionHeaderFactory(showRecentlyDiscovered = false) + .insertSeparator(before = appItems.first(), after = disabledItem) + ?.type + ) + + val updatableResult = createPagingSource( + showOnDevice = false, + filterId = Filters.UPDATABLE, + packageEnabled = { it != "disabled.watched" } + ).load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)) + + assertEquals( + listOf("enabled.watched"), + (updatableResult as PagingSource.LoadResult.Page) + .data + .filterIsInstance() + .map { it.appListItem.app.packageName } + ) + } + + @Test + fun disabledUpdateStatusIsClearedBeforePackageIsReenabled() = runBlocking { + insertApp( + appId = "disabled", + packageName = "disabled.watched", + title = "Disabled Watched", + versionNumber = 2, + status = App.STATUS_UPDATED, + syncTime = System.currentTimeMillis() + ) + val installedApps = InstalledApps.StaticMap( + mapOf( + "disabled.watched" to InstalledApps.Info(versionCode = 1, versionName = "1") + ) + ) + + val cleared = db.apps().clearDisabledUpdateStatuses( + installedApps = installedApps, + packageEnabled = { false } + ) + + assertEquals(1, cleared) + val storedApp = db.apps().loadApp("disabled")!! + assertEquals(App.STATUS_NORMAL, storedApp.status) + assertEquals(0L, storedApp.syncTime) + + val result = createPagingSource( + showOnDevice = false, + packageEnabled = { true }, + installedApps = installedApps + ).load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)) + val appItem = (result as PagingSource.LoadResult.Page) + .data + .filterIsInstance() + .single() + + assertEquals(App.STATUS_NORMAL, appItem.appListItem.app.status) + assertEquals( + SectionHeader.Watching, + DefaultSectionHeaderFactory(showRecentlyDiscovered = false) + .insertSeparator(before = null, after = appItem) + ?.type + ) + } + @Test fun showOnDeviceUsesUndefinedCountSoExactPageBoundaryCanLoadInstalledApps() = runBlocking { repeat(20) { index -> @@ -121,7 +234,6 @@ class WatchListPagingSourceRoomTest { } val unloadedRow = AppListTable.Queries.loadAppListRows( sortId = Preferences.SORT_NAME_ASC, - orderByRecentlyDiscovered = false, tagId = null, titleFilter = "", table = db.apps() @@ -157,7 +269,6 @@ class WatchListPagingSourceRoomTest { val firstPage = firstResult as PagingSource.LoadResult.Page val secondPageRow = AppListTable.Queries.loadAppListRows( sortId = Preferences.SORT_NAME_ASC, - orderByRecentlyDiscovered = false, tagId = null, titleFilter = "", table = db.apps() @@ -172,6 +283,64 @@ class WatchListPagingSourceRoomTest { assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, secondPage.itemsAfter) } + @Test + fun pagingSourceDoesNotRenderRowsWhoseVersionChangedAfterSnapshot() = runBlocking { + val packageNames = (0 until 40).map { index -> + "version.${index.toString().padStart(2, '0')}" + } + packageNames.forEachIndexed { index, packageName -> + insertApp( + appId = "version-$index", + packageName = packageName, + title = "Version ${index.toString().padStart(2, '0')}", + versionNumber = 2, + status = App.STATUS_UPDATED + ) + } + val installedApps = InstalledApps.StaticMap( + packageNames.associateWith { + InstalledApps.Info(versionCode = 1, versionName = "1") + } + ) + val pagingSource = createPagingSource( + showOnDevice = false, + filterId = Filters.UPDATABLE, + installedApps = installedApps + ) + + val firstResult = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false) + ) + val firstPage = firstResult as PagingSource.LoadResult.Page + val changedRow = AppListTable.Queries.loadAppListRows( + sortId = Preferences.SORT_NAME_ASC, + tagId = null, + titleFilter = "", + table = db.apps() + )[25] + db.openHelper.writableDatabase.execSQL( + "UPDATE ${AppListTable.TABLE} SET ${AppListTable.Columns.VERSION_NUMBER} = ? WHERE _id = ?", + arrayOf(1, changedRow.rowId) + ) + + val secondResult = pagingSource.load( + PagingSource.LoadParams.Append( + key = firstPage.nextKey!!, + loadSize = 20, + placeholdersEnabled = false + ) + ) + val secondPage = secondResult as PagingSource.LoadResult.Page + + assertTrue( + secondPage.data + .filterIsInstance() + .none { it.appListItem.app.rowId == changedRow.rowId } + ) + assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, secondPage.itemsBefore) + assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, secondPage.itemsAfter) + } + @Test fun pagingSourceKeepsRecentSectionsStableWhenUnloadedRowChanges() = runBlocking { repeat(5) { index -> @@ -194,7 +363,6 @@ class WatchListPagingSourceRoomTest { val firstPage = firstResult as PagingSource.LoadResult.Page val unloadedRow = AppListTable.Queries.loadAppListRows( sortId = Preferences.SORT_NAME_ASC, - orderByRecentlyDiscovered = true, tagId = null, titleFilter = "", table = db.apps() @@ -215,6 +383,78 @@ class WatchListPagingSourceRoomTest { assertEquals(1, itemsWithHeaders.count { it.sectionKey == "header:watching" }) } + @Test + fun packageEnabledStateIsPinnedForThePagingGeneration() = runBlocking { + val packageNames = (0 until 40).map { index -> "snapshot.${index.toString().padStart(2, '0')}" } + packageNames.forEachIndexed { index, packageName -> + insertApp( + appId = "snapshot-$index", + packageName = packageName, + title = "Snapshot ${index.toString().padStart(2, '0')}", + versionNumber = 2, + status = App.STATUS_UPDATED + ) + } + val installedApps = InstalledApps.StaticMap( + packageNames.associateWith { + InstalledApps.Info(versionCode = 1, versionName = "1") + } + ) + val enabledStates = packageNames.associateWith { true }.toMutableMap() + var enabledLookups = 0 + val packageEnabled: (String) -> Boolean = { packageName -> + enabledLookups++ + enabledStates.getValue(packageName) + } + val pagingSource = createPagingSource( + showOnDevice = false, + packageEnabled = packageEnabled, + installedApps = installedApps, + ) + + val firstResult = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false) + ) + val firstPage = firstResult as PagingSource.LoadResult.Page + assertEquals(40, enabledLookups) + + enabledStates["snapshot.25"] = false + val secondResult = pagingSource.load( + PagingSource.LoadParams.Append(key = firstPage.nextKey!!, loadSize = 20, placeholdersEnabled = false) + ) + val secondPage = secondResult as PagingSource.LoadResult.Page + val currentGenerationItems = (firstPage.data + secondPage.data).filterIsInstance() + + assertTrue(currentGenerationItems.single { it.appListItem.app.packageName == "snapshot.25" }.isPackageEnabled) + assertEquals(40, enabledLookups) + val currentGenerationWithHeaders = insertHeaders(currentGenerationItems) + assertEquals( + currentGenerationWithHeaders.map { it.sectionKey }.toSet().size, + currentGenerationWithHeaders.size + ) + assertEquals(1, currentGenerationWithHeaders.count { it.sectionKey == "header:new" }) + assertEquals(0, currentGenerationWithHeaders.count { it.sectionKey == "header:watching" }) + + val refreshedSource = createPagingSource( + showOnDevice = false, + packageEnabled = packageEnabled, + installedApps = installedApps, + ) + val refreshedResult = refreshedSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 40, placeholdersEnabled = false) + ) + val refreshedItems = (refreshedResult as PagingSource.LoadResult.Page) + .data + .filterIsInstance() + val refreshedWithHeaders = insertHeaders(refreshedItems) + + assertFalse(refreshedItems.single { it.appListItem.app.packageName == "snapshot.25" }.isPackageEnabled) + assertEquals(80, enabledLookups) + assertEquals(1, refreshedWithHeaders.count { it.sectionKey == "header:new" }) + assertEquals(1, refreshedWithHeaders.count { it.sectionKey == "header:watching" }) + assertEquals(refreshedWithHeaders.map { it.sectionKey }.toSet().size, refreshedWithHeaders.size) + } + @Test fun pagingSourceKeepsSortStableForOnDeviceItems() = runBlocking { repeat(20) { index -> @@ -248,9 +488,12 @@ class WatchListPagingSourceRoomTest { showOnDevice: Boolean, preferences: Preferences = createPreferences(), showRecentlyDiscovered: Boolean = false, + filterId: Int = Filters.ALL, + packageEnabled: (String) -> Boolean = { true }, + installedApps: InstalledApps = defaultInstalledApps(), ) = WatchListPagingSource( config = WatchListPagingSource.Config( - filterId = Filters.ALL, + filterId = filterId, tagId = null, showRecentlyDiscovered = showRecentlyDiscovered, showOnDevice = showOnDevice, @@ -259,14 +502,20 @@ class WatchListPagingSourceRoomTest { prefs = preferences, packageManager = context.packageManager, database = db, - installedApps = InstalledApps.StaticMap( - mapOf( - "local.only.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), - "local.only.device" to InstalledApps.Info(versionCode = 1, versionName = "1"), - "boundary.device" to InstalledApps.Info(versionCode = 1, versionName = "1"), - "sort.device.alpha" to InstalledApps.Info(versionCode = 1, versionName = "1"), - "sort.device.zulu" to InstalledApps.Info(versionCode = 1, versionName = "1"), - ) + packageEnabled = packageEnabled, + installedApps = installedApps + ) + + private fun defaultInstalledApps(): InstalledApps = InstalledApps.StaticMap( + mapOf( + "local.only.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "local.only.device" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "boundary.device" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "sort.device.alpha" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "sort.device.zulu" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "disabled.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "enabled.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "normal.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), ) ) @@ -298,6 +547,7 @@ class WatchListPagingSourceRoomTest { appId: String, packageName: String, title: String, + versionNumber: Int = 1, status: Int = App.STATUS_NORMAL, syncTime: Long = 0, ) { @@ -306,7 +556,7 @@ class WatchListPagingSourceRoomTest { rowId = 0, appId = appId, packageName = packageName, - versionNumber = 1, + versionNumber = versionNumber, versionName = "1.0", title = title, creator = "creator", From da4f3a67ce12ff12b44b959f968cecbd5b15219b Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:47:00 +0300 Subject: [PATCH 2/4] Address disabled app review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../anod/appwatcher/compose/IconsResources.kt | 6 +-- .../anod/appwatcher/database/AppListTable.kt | 8 ++++ .../installed/InstalledListPagerFactory.kt | 10 +++-- .../installed/InstalledPagingSource.kt | 3 +- .../installed/PackageRemovedReceiver.kt | 5 ++- .../appwatcher/utils/PackageEnabledState.kt | 13 +++++++ .../watchlist/AppsWatchListPagerFactory.kt | 4 +- .../watchlist/WatchListPagingSource.kt | 3 +- .../WatchListPagingSourceRoomTest.kt | 37 +++++++++++++++++++ 9 files changed, 75 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt b/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt index 3f503d39..4fa9ed46 100644 --- a/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt +++ b/app/src/main/java/com/anod/appwatcher/compose/IconsResources.kt @@ -119,9 +119,9 @@ fun ShareIcon() { @Composable fun InstalledSignIcon( + enabled: Boolean, modifier: Modifier = Modifier, - tint: Color = LocalContentColor.current, - enabled: Boolean = true + tint: Color = LocalContentColor.current ) { Icon( imageVector = if (enabled) Icons.Default.Smartphone else Icons.Default.PhonelinkOff, @@ -224,7 +224,7 @@ private fun IconsResourcesPreview() { { PlayStoreAppIcon() }, { PlayStoreMyAppsIcon() }, { ShareIcon() }, - { InstalledSignIcon() }, + { InstalledSignIcon(enabled = true) }, { StoreVersionSignIcon() }, { WatchedIcon(unwatch = true) }, { WatchedIcon(unwatch = false) }, diff --git a/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt b/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt index c8ee189d..301c7026 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt @@ -152,6 +152,14 @@ interface AppListTable { ) suspend fun clearUpdateStatus(rowId: Int): Int + @Query( + "UPDATE $TABLE SET " + + "${Columns.STATUS} = ${App.STATUS_NORMAL}, " + + "${Columns.SYNC_TIMESTAMP} = 0 " + + "WHERE ${Columns.PACKAGE_NAME} = :packageName AND ${Columns.STATUS} = ${App.STATUS_UPDATED}" + ) + suspend fun clearUpdateStatusByPackageName(packageName: String): Int + @Query( "UPDATE $TABLE SET " + "${Columns.STATUS} = ${App.STATUS_NORMAL}, " + diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt index 7018da03..d846ae61 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt @@ -3,6 +3,7 @@ package com.anod.appwatcher.installed import android.content.pm.PackageManager import com.anod.appwatcher.database.AppsDatabase +import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.watchlist.FilterablePagingSource import com.anod.appwatcher.watchlist.SectionHeaderFactory import com.anod.appwatcher.watchlist.WatchListPagerFactory @@ -34,10 +35,11 @@ class InstalledListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config } override fun createPagingSource(): FilterablePagingSource = InstalledPagingSource( - changelogAdapter, - packageManager, - database, - installedApps + changelogAdapter = changelogAdapter, + packageManager = packageManager, + database = database, + installedApps = installedApps, + packageEnabled = packageManager::isPackageEnabled ).also { it.sortId = sortId it.selectionMode = selectionMode diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt index 6515a2db..4c2235f4 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt @@ -7,7 +7,6 @@ import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppListItem import com.anod.appwatcher.preferences.Preferences -import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.watchlist.FilterablePagingSource import com.anod.appwatcher.watchlist.SectionItem import info.anodsplace.applog.AppLog @@ -19,7 +18,7 @@ class InstalledPagingSource( private val packageManager: PackageManager, private val database: AppsDatabase, private val installedApps: InstalledApps, - private val packageEnabled: (String) -> Boolean = { packageManager.isPackageEnabled(it) }, + private val packageEnabled: (String) -> Boolean, ) : FilterablePagingSource() { override var filterQuery: String = "" diff --git a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt index cd04a8d6..be2789a6 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt @@ -8,7 +8,7 @@ import android.content.pm.PackageManager import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.utils.PackageChangedReceiver import com.anod.appwatcher.utils.appScope -import com.anod.appwatcher.utils.clearDisabledUpdateStatuses +import com.anod.appwatcher.utils.clearDisabledUpdateStatus import com.anod.appwatcher.utils.isPackageEnabled import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.launch @@ -42,7 +42,8 @@ class PackageRemovedReceiver : BroadcastReceiver(), KoinComponent { try { if (clearDisabledUpdates) { val packageManager = get() - get().apps().clearDisabledUpdateStatuses( + get().apps().clearDisabledUpdateStatus( + packageName = packageName, installedApps = InstalledApps.PackageManager(packageManager), packageEnabled = packageManager::isPackageEnabled ) diff --git a/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt index 54e40fcf..4a3c9bcb 100644 --- a/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt +++ b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt @@ -29,6 +29,19 @@ internal fun isPackageEnabled(manifestEnabled: Boolean, enabledSetting: Int): Bo else -> manifestEnabled } +internal suspend fun AppListTable.clearDisabledUpdateStatus( + packageName: String, + installedApps: InstalledApps, + packageEnabled: (String) -> Boolean +): Int = withContext(Dispatchers.IO) { + val packageInfo = installedApps.packageInfo(packageName) + if (packageInfo.isInstalled && !packageEnabled(packageName)) { + clearUpdateStatusByPackageName(packageName) + } else { + 0 + } +} + internal suspend fun AppListTable.clearDisabledUpdateStatuses( installedApps: InstalledApps, packageEnabled: (String) -> Boolean diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt b/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt index 9dbac033..d2788a17 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt @@ -2,6 +2,7 @@ package com.anod.appwatcher.watchlist import android.content.pm.PackageManager import com.anod.appwatcher.database.AppsDatabase +import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.utils.prefs import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.CoroutineScope @@ -19,7 +20,8 @@ class AppsWatchListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config config = pagingSourceConfig, packageManager = packageManager, database = database, - installedApps = installedApps + installedApps = installedApps, + packageEnabled = packageManager::isPackageEnabled ).also { it.filterQuery = filterQuery } diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt index 88664b24..805cfebf 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt @@ -14,7 +14,6 @@ import com.anod.appwatcher.installed.InstalledTaskWorker import com.anod.appwatcher.model.AppListFilter import com.anod.appwatcher.model.Filters import com.anod.appwatcher.preferences.Preferences -import com.anod.appwatcher.utils.isPackageEnabled import info.anodsplace.applog.AppLog import info.anodsplace.framework.content.InstalledApps import kotlin.math.max @@ -27,7 +26,7 @@ class WatchListPagingSource( private val packageManager: PackageManager, private val database: AppsDatabase, private val installedApps: InstalledApps, - private val packageEnabled: (String) -> Boolean = { packageManager.isPackageEnabled(it) }, + private val packageEnabled: (String) -> Boolean, ) : FilterablePagingSource() { override var filterQuery: String = "" set(value) { diff --git a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt index 9f96470f..85ad6f14 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt @@ -12,6 +12,7 @@ import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.Price import com.anod.appwatcher.model.Filters import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.clearDisabledUpdateStatus import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import info.anodsplace.framework.content.InstalledApps import info.anodsplace.notification.NotificationManager @@ -181,6 +182,42 @@ class WatchListPagingSourceRoomTest { ) } + @Test + fun packageChangeClearsOnlyChangedDisabledUpdateStatus() = runBlocking { + insertApp( + appId = "disabled", + packageName = "disabled.watched", + title = "Disabled Watched", + versionNumber = 2, + status = App.STATUS_UPDATED, + syncTime = System.currentTimeMillis() + ) + insertApp( + appId = "other", + packageName = "other.watched", + title = "Other Watched", + versionNumber = 2, + status = App.STATUS_UPDATED, + syncTime = System.currentTimeMillis() + ) + val installedApps = InstalledApps.StaticMap( + mapOf( + "disabled.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), + "other.watched" to InstalledApps.Info(versionCode = 1, versionName = "1") + ) + ) + + val cleared = db.apps().clearDisabledUpdateStatus( + packageName = "disabled.watched", + installedApps = installedApps, + packageEnabled = { false } + ) + + assertEquals(1, cleared) + assertEquals(App.STATUS_NORMAL, db.apps().loadApp("disabled")!!.status) + assertEquals(App.STATUS_UPDATED, db.apps().loadApp("other")!!.status) + } + @Test fun showOnDeviceUsesUndefinedCountSoExactPageBoundaryCanLoadInstalledApps() = runBlocking { repeat(20) { index -> From 411dacac37aef04ed5fdeb17ba0c254599e19508 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:39:27 +0300 Subject: [PATCH 3/4] Optimize package state refreshes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../java/com/anod/appwatcher/AppModule.kt | 4 +- .../installed/InstalledListPagerFactory.kt | 8 +- .../installed/InstalledListScreen.kt | 8 +- .../installed/InstalledListViewModel.kt | 6 +- .../installed/InstalledPagingSource.kt | 13 +- .../installed/PackageRemovedReceiver.kt | 38 ++-- .../com/anod/appwatcher/sync/UpdateCheck.kt | 60 +++--- .../appwatcher/utils/PackageEnabledState.kt | 176 +++++++++++++++++- .../watchlist/AppsWatchListPagerFactory.kt | 8 +- .../watchlist/WatchListPagingSource.kt | 22 ++- .../watchlist/WatchListStateViewModel.kt | 16 +- .../installed/PackageRemovedReceiverTest.kt | 38 ++++ .../utils/PackageEnabledStateTest.kt | 46 +++++ .../WatchListPagingSourceRoomTest.kt | 30 ++- 14 files changed, 377 insertions(+), 96 deletions(-) create mode 100644 app/src/test/java/com/anod/appwatcher/installed/PackageRemovedReceiverTest.kt diff --git a/app/src/main/java/com/anod/appwatcher/AppModule.kt b/app/src/main/java/com/anod/appwatcher/AppModule.kt index 341dbd6e..0d59468e 100644 --- a/app/src/main/java/com/anod/appwatcher/AppModule.kt +++ b/app/src/main/java/com/anod/appwatcher/AppModule.kt @@ -15,6 +15,7 @@ import com.anod.appwatcher.preferences.Preferences import com.anod.appwatcher.sync.UpdateCheck import com.anod.appwatcher.utils.AppIconLoader import com.anod.appwatcher.utils.PackageChangedReceiver +import com.anod.appwatcher.utils.PackageStateCache import com.anod.appwatcher.utils.RealAppIconLoader import com.anod.appwatcher.utils.date.UploadDateParserCache import com.anod.appwatcher.watchlist.RecentlyInstalledAppsLoader @@ -64,6 +65,7 @@ fun createAppModule(): Module = module { single>(named("memoryCache")) { createLruCache() } single { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) } singleOf(::PackageChangedReceiver) + singleOf(::PackageStateCache) singleOf(::AuthTokenBlocking) single { HttpClient(OkHttp) { @@ -85,7 +87,7 @@ fun createAppModule(): Module = module { factory { UpdateCheck( context = get(), - packageManager = get(), + packageStates = get(), notificationManager = get(), database = get(), authAccount = get(), diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt index d846ae61..7d9c054c 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListPagerFactory.kt @@ -3,18 +3,17 @@ package com.anod.appwatcher.installed import android.content.pm.PackageManager import com.anod.appwatcher.database.AppsDatabase -import com.anod.appwatcher.utils.isPackageEnabled +import com.anod.appwatcher.utils.PackageStateProvider import com.anod.appwatcher.watchlist.FilterablePagingSource import com.anod.appwatcher.watchlist.SectionHeaderFactory import com.anod.appwatcher.watchlist.WatchListPagerFactory import com.anod.appwatcher.watchlist.WatchListPagingSource -import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.CoroutineScope import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.core.parameter.parametersOf -class InstalledListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config, coroutineScope: CoroutineScope, private val installedApps: InstalledApps, cacheScope: CoroutineScope) : +class InstalledListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config, coroutineScope: CoroutineScope, private val packageStates: PackageStateProvider, cacheScope: CoroutineScope) : WatchListPagerFactory(pagingSourceConfig, cacheScope), KoinComponent { @@ -38,8 +37,7 @@ class InstalledListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config changelogAdapter = changelogAdapter, packageManager = packageManager, database = database, - installedApps = installedApps, - packageEnabled = packageManager::isPackageEnabled + packageStates = packageStates ).also { it.sortId = sortId it.selectionMode = selectionMode diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt index aa097e9c..4ff0563e 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListScreen.kt @@ -31,10 +31,10 @@ import com.anod.appwatcher.model.Filters import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.navigation.asNavKey import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.PackageStateProvider import com.anod.appwatcher.watchlist.WatchListPage import com.anod.appwatcher.watchlist.WatchListPagingSource import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.InstalledApps import info.anodsplace.framework.content.onScreenCommonAction import kotlinx.coroutines.CoroutineScope @@ -73,7 +73,7 @@ fun InstalledListScreenScene( screenState = screenState, pagingSourceConfig = pagingSourceConfig, onEvent = viewModel::handleEvent, - installedApps = viewModel.installedApps, + packageStates = viewModel.packageStates, listCacheScope = viewModel.viewModelScope ) } @@ -90,7 +90,7 @@ fun InstalledListScreen( screenState: InstalledListState, pagingSourceConfig: WatchListPagingSource.Config, onEvent: (InstalledListEvent) -> Unit, - installedApps: InstalledApps, + packageStates: PackageStateProvider, listCacheScope: CoroutineScope ) { Scaffold( @@ -139,7 +139,7 @@ fun InstalledListScreen( Box(modifier = Modifier.padding(paddingValues)) { val scope = rememberCoroutineScope() val pagerFactory: InstalledListPagerFactory = remember(pagingSourceConfig, scope) { - InstalledListPagerFactory(pagingSourceConfig, scope, installedApps, listCacheScope) + InstalledListPagerFactory(pagingSourceConfig, scope, packageStates, listCacheScope) } pagerFactory.sortId = screenState.sortId pagerFactory.selectionMode = screenState.selectionMode diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt index d5861c1a..6f2f1366 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledListViewModel.kt @@ -17,13 +17,13 @@ import com.anod.appwatcher.accounts.toAndroidAccount import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.utils.BaseFlowViewModel import com.anod.appwatcher.utils.PackageChangedReceiver +import com.anod.appwatcher.utils.PackageStateCache import com.anod.appwatcher.utils.SelectionState import com.anod.appwatcher.utils.filterWithExtra import com.anod.appwatcher.utils.getInt import com.anod.appwatcher.utils.networkConnection import com.anod.appwatcher.utils.prefs import com.anod.appwatcher.watchlist.WatchListEvent -import info.anodsplace.framework.content.InstalledApps import info.anodsplace.framework.content.ScreenCommonAction import info.anodsplace.framework.content.getInstalledPackagesCodes import kotlin.reflect.KClass @@ -62,10 +62,9 @@ class InstalledListViewModel(state: SavedStateHandle, showAction: Boolean, sortI private val importManager: ImportBulkManager by inject() private val packageManager: PackageManager by inject() private val packageChanged: PackageChangedReceiver by inject() + val packageStates: PackageStateCache by inject() private val authToken: AuthTokenBlocking by inject() - val installedApps = InstalledApps.MemoryCache(InstalledApps.PackageManager(packageManager)) - class Factory(private val sortId: Int, private val showAction: Boolean,) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: KClass, extras: CreationExtras): T = InstalledListViewModel( @@ -84,7 +83,6 @@ class InstalledListViewModel(state: SavedStateHandle, showAction: Boolean, sortI viewModelScope.launch { packageChanged.observer.collect { packageChanged -> if (viewState.importStatus !is ImportStatus.Progress) { - installedApps.reset() viewState = viewState.copy(packageChanged = packageChanged) } } diff --git a/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt b/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt index 4c2235f4..4fb6bdfa 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/InstalledPagingSource.kt @@ -7,18 +7,18 @@ import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppListItem import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.PackageStateProvider +import com.anod.appwatcher.utils.stateFor import com.anod.appwatcher.watchlist.FilterablePagingSource import com.anod.appwatcher.watchlist.SectionItem import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.InstalledApps import info.anodsplace.ktx.dayStartAgoMillis class InstalledPagingSource( private val changelogAdapter: ChangelogAdapter, private val packageManager: PackageManager, private val database: AppsDatabase, - private val installedApps: InstalledApps, - private val packageEnabled: (String) -> Boolean, + private val packageStates: PackageStateProvider, ) : FilterablePagingSource() { override var filterQuery: String = "" @@ -29,6 +29,7 @@ class InstalledPagingSource( AppLog.d("$params") val installed = InstalledTaskWorker(packageManager, sortId, filterQuery).run() val allInstalledPackageNames = installed.map { it.pkg.name } + val installedPackageStates = packageStates.load(allInstalledPackageNames) val watchingPackages = database.apps().loadRowIds(allInstalledPackageNames).associateBy({ it.packageName }, { it.rowId }) if (sortId == Preferences.SORT_DATE_ASC || sortId == Preferences.SORT_DATE_DESC) { @@ -53,7 +54,7 @@ class InstalledPagingSource( } .map { app -> val appChange = changelogAdapter.changelogs[app.appId] - val packageInfo = installedApps.packageInfo(app.packageName) + val packageState = installedPackageStates.stateFor(app.packageName) SectionItem.OnDevice( appListItem = AppListItem( app = app, @@ -62,8 +63,8 @@ class InstalledPagingSource( recentFlag = false ), showSelection = selectionMode, - packageInfo = packageInfo, - isPackageEnabled = !packageInfo.isInstalled || packageEnabled(app.packageName) + packageInfo = packageState.packageInfo, + isPackageEnabled = packageState.isEnabled ) }.toList() diff --git a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt index be2789a6..de61c9a9 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt @@ -4,13 +4,11 @@ package com.anod.appwatcher.installed import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.utils.PackageChangedReceiver +import com.anod.appwatcher.utils.PackageStateCache import com.anod.appwatcher.utils.appScope import com.anod.appwatcher.utils.clearDisabledUpdateStatus -import com.anod.appwatcher.utils.isPackageEnabled -import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -19,39 +17,51 @@ class PackageRemovedReceiver : BroadcastReceiver(), KoinComponent { override fun onReceive(context: Context?, intent: Intent?) { val action = intent?.action ?: return + val packageName = intent.data?.schemeSpecificPart ?: return when (action) { Intent.ACTION_PACKAGE_FULLY_REMOVED -> { - notify(intent, clearDisabledUpdates = false) + notify(packageName, clearDisabledUpdates = false, requireStateChange = false) } Intent.ACTION_PACKAGE_ADDED -> { - notify(intent, clearDisabledUpdates = true) + notify(packageName, clearDisabledUpdates = true, requireStateChange = false) } Intent.ACTION_PACKAGE_CHANGED -> { - notify(intent, clearDisabledUpdates = true) + if (isApplicationPackageChange(packageName, intent.getStringArrayExtra(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST))) { + notify(packageName, clearDisabledUpdates = true, requireStateChange = true) + } } Intent.ACTION_PACKAGE_REPLACED -> { - notify(intent, clearDisabledUpdates = true) + notify(packageName, clearDisabledUpdates = true, requireStateChange = false) } } } - private fun notify(intent: Intent, clearDisabledUpdates: Boolean) { - val packageName = intent.data?.schemeSpecificPart ?: "" + private fun notify( + packageName: String, + clearDisabledUpdates: Boolean, + requireStateChange: Boolean + ) { val pendingResult = goAsync() appScope.launch { try { + val packageStateRefresh = get().refresh(packageName) if (clearDisabledUpdates) { - val packageManager = get() get().apps().clearDisabledUpdateStatus( packageName = packageName, - installedApps = InstalledApps.PackageManager(packageManager), - packageEnabled = packageManager::isPackageEnabled + packageState = packageStateRefresh.current ) } - get().emit(packageName + ":" + System.currentTimeMillis()) + if (!requireStateChange || packageStateRefresh.changed) { + get().emit(packageName + ":" + System.currentTimeMillis()) + } } finally { pendingResult.finish() } } } -} \ No newline at end of file +} + +internal fun isApplicationPackageChange( + packageName: String, + changedComponents: Array? +): Boolean = changedComponents == null || packageName in changedComponents \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt b/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt index 2b64e625..7ca8ee12 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt @@ -2,7 +2,6 @@ package com.anod.appwatcher.sync import android.content.ContentValues import android.content.Intent -import android.content.pm.PackageManager import android.os.Build import android.os.RemoteException import android.provider.BaseColumns @@ -28,11 +27,13 @@ import com.anod.appwatcher.database.entities.Schedule import com.anod.appwatcher.database.entities.preserveCachedMetadata import com.anod.appwatcher.database.entities.toApp import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.PackageState +import com.anod.appwatcher.utils.PackageStateCache import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import com.anod.appwatcher.utils.compareLettersAndDigits import com.anod.appwatcher.utils.date.UploadDateParserCache import com.anod.appwatcher.utils.extractUploadDate -import com.anod.appwatcher.utils.isPackageEnabled +import com.anod.appwatcher.utils.stateFor import finsky.api.BulkDocId import finsky.api.DfeApi import finsky.api.DfeServerError @@ -60,7 +61,7 @@ import org.koin.core.parameter.parametersOf class UpdateCheck( private val context: info.anodsplace.context.ApplicationContext, - private val packageManager: PackageManager, + private val packageStates: PackageStateCache, private val notificationManager: info.anodsplace.notification.NotificationManager, private val database: AppsDatabase, private val preferences: Preferences, @@ -125,8 +126,6 @@ class UpdateCheck( const val EXTRA_UPDATES_COUNT = "extra_updates_count" } - private val installedAppsProvider = InstalledApps.PackageManager(packageManager) - suspend fun perform(extras: Data): Int = playSessionCoordinator.withSession { performSerialized(extras) } @@ -169,7 +168,8 @@ class UpdateCheck( context.sendBroadcast(startIntent) AppLog.d("Last update viewed: $lastUpdatesViewed") SchedulesTable.Queries.save(schedule, database) - val playStoreVersion = installedAppsProvider.packageInfo("com.android.vending").versionCode + val installedPackageStates = packageStates.reload(listOf("com.android.vending")) + val playStoreVersion = installedPackageStates.stateFor("com.android.vending").packageInfo.versionCode AppLog.i( "Play Store update check started (syncId=${schedule.id}, " + "type=${if (manualSync) "manual" else "scheduled"}, build=${BuildConfig.VERSION_CODE}, " + @@ -179,7 +179,11 @@ class UpdateCheck( ) try { - doSync(lastUpdatesViewed, schedule.id, verboseDiagnostics = manualSync) + doSync( + lastUpdatesViewed = lastUpdatesViewed, + syncId = schedule.id, + verboseDiagnostics = manualSync + ) } catch (e: AuthTokenStartIntent) { throw e } catch (e: CancellationException) { @@ -247,8 +251,7 @@ class UpdateCheck( verboseDiagnostics: Boolean ): SyncResult { val clearedDisabledUpdates = database.apps().clearDisabledUpdateStatuses( - installedApps = installedAppsProvider, - packageEnabled = packageManager::isPackageEnabled + packageStates = packageStates ) if (clearedDisabledUpdates > 0) { AppLog.i( @@ -271,6 +274,9 @@ class UpdateCheck( } finally { apps.close() } + val installedPackageStates = packageStates.load( + localAppChunks.flatMap { it.keys } + ) var unavailable = 0 val fetchedChunks = fetchAllChunks( chunks = localAppChunks, @@ -294,14 +300,16 @@ class UpdateCheck( localApps = localApps, packageNames = classification.missingDocIds, decision = AppUpdateDecision.MISSING_RESPONSE_KEEP, - verboseDiagnostics = verboseDiagnostics + verboseDiagnostics = verboseDiagnostics, + installedPackageStates = installedPackageStates ) logUnusableDocuments( syncId = syncId, localApps = localApps, packageNames = classification.withoutDetailsDocIds, decision = AppUpdateDecision.RESPONSE_WITHOUT_DETAILS_KEEP, - verboseDiagnostics = verboseDiagnostics + verboseDiagnostics = verboseDiagnostics, + installedPackageStates = installedPackageStates ) val availabilitySummary = documents .groupingBy { it.availabilityRestriction?.toString() ?: "absent" } @@ -333,7 +341,8 @@ class UpdateCheck( lastUpdatesViewed, releaseDetails, syncId, - verboseDiagnostics + verboseDiagnostics, + installedPackageStates ) updatedApps.addAll(applyAppUpdates(pendingUpdates, database)) } @@ -395,20 +404,21 @@ class UpdateCheck( localApps: Map, packageNames: Set, decision: AppUpdateDecision, - verboseDiagnostics: Boolean + verboseDiagnostics: Boolean, + installedPackageStates: Map ) { if (!verboseDiagnostics) { return } for (packageName in packageNames) { val localItem = localApps.getValue(packageName) - val installedInfo = installedAppsProvider.packageInfo(packageName) + val packageState = installedPackageStates.stateFor(packageName) logSyncDiagnostic( diagnostic = AppSyncDiagnostic( syncId = syncId, packageName = packageName, - installedVersion = installedInfo.versionCode, - installedEnabled = installedInfo.isInstalled && packageManager.isPackageEnabled(packageName), + installedVersion = packageState.packageInfo.versionCode, + installedEnabled = packageState.isInstalled && packageState.isEnabled, cachedVersion = localItem.app.versionNumber, updateRemoteVersion = null, fullRemoteVersion = null, @@ -475,7 +485,8 @@ class UpdateCheck( lastUpdatesViewed: Boolean, releaseDetails: Map, syncId: Long, - verboseDiagnostics: Boolean + verboseDiagnostics: Boolean, + installedPackageStates: Map ): List { val pendingUpdates = mutableListOf() for (marketApp in documents) { @@ -487,7 +498,13 @@ class UpdateCheck( val releaseApp = fullDocument ?.takeIf { it.appDetails.versionCode == marketApp.appDetails.versionCode } ?: marketApp - val result = updateApp(marketApp, releaseApp, localItem, lastUpdatesViewed) + val result = updateApp( + marketDoc = marketApp, + releaseDoc = releaseApp, + localItem = localItem, + lastUpdatesViewed = lastUpdatesViewed, + packageState = installedPackageStates.stateFor(localItem.app.packageName) + ) val diagnostic = AppSyncDiagnostic( syncId = syncId, packageName = localItem.app.packageName, @@ -596,14 +613,15 @@ class UpdateCheck( marketDoc: Document, releaseDoc: Document, localItem: AppListItem, - lastUpdatesViewed: Boolean + lastUpdatesViewed: Boolean, + packageState: PackageState ): AppUpdateResult { val appDetails = marketDoc.appDetails val localApp = localItem.app val values = ContentValues() - val installedInfo = installedAppsProvider.packageInfo(appDetails.packageName) - val installedEnabled = installedInfo.isInstalled && packageManager.isPackageEnabled(appDetails.packageName) + val installedInfo = packageState.packageInfo + val installedEnabled = packageState.isInstalled && packageState.isEnabled val unavailableAction = reconcileUnavailableUpdate(marketDoc, localApp, installedInfo, values) if (unavailableAction != UnavailableUpdateAction.NONE) { return AppUpdateResult( diff --git a/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt index 4a3c9bcb..684ff682 100644 --- a/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt +++ b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt @@ -1,9 +1,13 @@ package com.anod.appwatcher.utils +import android.content.pm.PackageInfo import android.content.pm.PackageManager +import androidx.core.content.pm.PackageInfoCompat import com.anod.appwatcher.database.AppListTable import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext fun PackageManager.isPackageEnabled(packageName: String): Boolean { @@ -29,13 +33,163 @@ internal fun isPackageEnabled(manifestEnabled: Boolean, enabledSetting: Int): Bo else -> manifestEnabled } +data class PackageState( + val packageInfo: InstalledApps.Info, + val isEnabled: Boolean +) { + val isInstalled: Boolean = packageInfo.isInstalled + + companion object { + val NotInstalled = PackageState( + packageInfo = InstalledApps.Info(versionCode = 0, versionName = ""), + isEnabled = true + ) + } +} + +fun interface PackageStateProvider { + suspend fun load(packageNames: Collection): Map +} + +data class PackageStateRefresh( + val current: PackageState, + val changed: Boolean +) + +class PackageStateCache(private val packageManager: PackageManager) : PackageStateProvider { + private val mutex = Mutex() + + @Volatile + private var cachedStates: Map? = null + + override suspend fun load(packageNames: Collection): Map { + if (packageNames.isEmpty()) { + return emptyMap() + } + cachedStates?.let { states -> + if (packageNames.all(states::containsKey)) { + return states + } + } + return mutex.withLock { + val currentStates = cachedStates + if (currentStates == null) { + loadInstalledPackageStates(packageNames).also { + cachedStates = it + } + } else { + val missingPackageNames = packageNames.distinct().filterNot { it in currentStates } + if (missingPackageNames.isEmpty()) { + currentStates + } else { + withContext(Dispatchers.IO) { + val updatedStates = currentStates.toMutableMap() + for (packageName in missingPackageNames) { + updatedStates[packageName] = packageManager.loadPackageState(packageName) + } + updatedStates + }.also { + cachedStates = it + } + } + } + } + } + + suspend fun reload(packageNames: Collection): Map = mutex.withLock { + loadInstalledPackageStates(packageNames).also { + cachedStates = it + } + } + + suspend fun refresh(packageName: String): PackageStateRefresh = mutex.withLock { + val current = withContext(Dispatchers.IO) { + packageManager.loadPackageState(packageName) + } + val states = cachedStates + if (states == null) { + return@withLock PackageStateRefresh(current = current, changed = true) + } + val previous = states[packageName] ?: PackageState.NotInstalled + if (states[packageName] != current) { + cachedStates = states.toMutableMap().apply { + put(packageName, current) + } + } + PackageStateRefresh(current = current, changed = previous != current) + } + + suspend fun clear() { + mutex.withLock { + cachedStates = null + } + } + + private suspend fun loadInstalledPackageStates( + packageNames: Collection = emptyList() + ): Map = withContext(Dispatchers.IO) { + val installedStates = packageManager.installedPackages().associateTo(mutableMapOf()) { packageInfo -> + packageInfo.packageName to packageInfo.toPackageState() + } + for (packageName in packageNames) { + if (packageName !in installedStates) { + installedStates[packageName] = packageManager.loadPackageState(packageName) + } + } + installedStates + } + + private fun PackageInfo.toPackageState(): PackageState { + val applicationEnabled = applicationInfo?.enabled ?: packageManager.isPackageEnabled(packageName) + return PackageState( + packageInfo = InstalledApps.Info( + versionCode = PackageInfoCompat.getLongVersionCode(this).toInt(), + versionName = versionName.orEmpty() + ), + isEnabled = applicationEnabled + ) + } + + private fun PackageManager.loadPackageState(packageName: String): PackageState { + val packageInfo = try { + @Suppress("DEPRECATION") + getPackageInfo(packageName, PackageManager.MATCH_DISABLED_COMPONENTS) + } catch (_: PackageManager.NameNotFoundException) { + return PackageState.NotInstalled + } + val applicationInfo = packageInfo.applicationInfo + val enabled = if (applicationInfo == null) { + isPackageEnabled(packageName) + } else { + val enabledSetting = try { + getApplicationEnabledSetting(packageName) + } catch (_: IllegalArgumentException) { + return PackageState.NotInstalled + } + isPackageEnabled(applicationInfo.enabled, enabledSetting) + } + return PackageState( + packageInfo = InstalledApps.Info( + versionCode = PackageInfoCompat.getLongVersionCode(packageInfo).toInt(), + versionName = packageInfo.versionName.orEmpty() + ), + isEnabled = enabled + ) + } + + @Suppress("DEPRECATION") + private fun PackageManager.installedPackages(): List = + getInstalledPackages(PackageManager.MATCH_DISABLED_COMPONENTS) +} + +internal fun Map.stateFor(packageName: String): PackageState = + get(packageName) ?: PackageState.NotInstalled + internal suspend fun AppListTable.clearDisabledUpdateStatus( packageName: String, - installedApps: InstalledApps, - packageEnabled: (String) -> Boolean + packageState: PackageState ): Int = withContext(Dispatchers.IO) { - val packageInfo = installedApps.packageInfo(packageName) - if (packageInfo.isInstalled && !packageEnabled(packageName)) { + if (packageState.isInstalled && !packageState.isEnabled) { clearUpdateStatusByPackageName(packageName) } else { 0 @@ -43,13 +197,17 @@ internal suspend fun AppListTable.clearDisabledUpdateStatus( } internal suspend fun AppListTable.clearDisabledUpdateStatuses( - installedApps: InstalledApps, - packageEnabled: (String) -> Boolean + packageStates: PackageStateProvider ): Int = withContext(Dispatchers.IO) { - val disabledRowIds = loadUpdatedPackages().mapNotNull { row -> - val packageInfo = installedApps.packageInfo(row.packageName) + val updatedPackages = loadUpdatedPackages() + if (updatedPackages.isEmpty()) { + return@withContext 0 + } + val states = packageStates.load(updatedPackages.map { it.packageName }) + val disabledRowIds = updatedPackages.mapNotNull { row -> + val packageState = states.stateFor(row.packageName) row.rowId.takeIf { - packageInfo.isInstalled && !packageEnabled(row.packageName) + packageState.isInstalled && !packageState.isEnabled } } var cleared = 0 diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt b/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt index d2788a17..e05677e7 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/AppsWatchListPagerFactory.kt @@ -2,14 +2,13 @@ package com.anod.appwatcher.watchlist import android.content.pm.PackageManager import com.anod.appwatcher.database.AppsDatabase -import com.anod.appwatcher.utils.isPackageEnabled +import com.anod.appwatcher.utils.PackageStateProvider import com.anod.appwatcher.utils.prefs -import info.anodsplace.framework.content.InstalledApps import kotlinx.coroutines.CoroutineScope import org.koin.core.component.KoinComponent import org.koin.core.component.inject -class AppsWatchListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config, private val installedApps: InstalledApps, cacheScope: CoroutineScope) : +class AppsWatchListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config, private val packageStates: PackageStateProvider, cacheScope: CoroutineScope) : WatchListPagerFactory(pagingSourceConfig, cacheScope), KoinComponent { private val database: AppsDatabase by inject() @@ -20,8 +19,7 @@ class AppsWatchListPagerFactory(pagingSourceConfig: WatchListPagingSource.Config config = pagingSourceConfig, packageManager = packageManager, database = database, - installedApps = installedApps, - packageEnabled = packageManager::isPackageEnabled + packageStates = packageStates ).also { it.filterQuery = filterQuery } diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt index 805cfebf..1933bf42 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt @@ -14,6 +14,8 @@ import com.anod.appwatcher.installed.InstalledTaskWorker import com.anod.appwatcher.model.AppListFilter import com.anod.appwatcher.model.Filters import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.PackageStateProvider +import com.anod.appwatcher.utils.stateFor import info.anodsplace.applog.AppLog import info.anodsplace.framework.content.InstalledApps import kotlin.math.max @@ -25,8 +27,7 @@ class WatchListPagingSource( private val prefs: Preferences, private val packageManager: PackageManager, private val database: AppsDatabase, - private val installedApps: InstalledApps, - private val packageEnabled: (String) -> Boolean, + private val packageStates: PackageStateProvider, ) : FilterablePagingSource() { override var filterQuery: String = "" set(value) { @@ -181,15 +182,15 @@ class WatchListPagingSource( lockedFilterQuery, database.apps() ) + val installedPackageStates = packageStates.load(databaseRows.map { it.packageName }) val rows = databaseRows .mapIndexed { index, row -> - val packageInfo = installedApps.packageInfo(row.packageName) - val isPackageEnabled = !packageInfo.isInstalled || packageEnabled(row.packageName) + val packageState = installedPackageStates.stateFor(row.packageName) AppListSnapshotRow( databaseRow = row, - packageInfo = packageInfo, - isPackageEnabled = isPackageEnabled, - sectionRank = sectionRank(row, isPackageEnabled), + packageInfo = packageState.packageInfo, + isPackageEnabled = packageState.isEnabled, + sectionRank = sectionRank(row, packageState.isEnabled), sortPosition = index, ) } @@ -221,6 +222,7 @@ class WatchListPagingSource( private suspend fun loadOnDeviceItems(titleFilter: String): List { val installed = InstalledTaskWorker(packageManager, sortId, titleFilter).run() val allInstalledPackageNames = installed.map { it.pkg.name } + val installedPackageStates = packageStates.load(allInstalledPackageNames) val watchingPackages = database.apps().loadRowIds(allInstalledPackageNames).associateBy({ it.packageName }, { it.rowId }) return allInstalledPackageNames .asSequence() @@ -228,12 +230,12 @@ class WatchListPagingSource( .map { packageManager.packageToApp(-1, it) } .map { app -> AppListItem(app, "", noNewDetails = false, recentFlag = false) } .map { item -> - val packageInfo = installedApps.packageInfo(item.app.packageName) + val packageState = installedPackageStates.stateFor(item.app.packageName) SectionItem.OnDevice( appListItem = item, showSelection = false, - packageInfo = packageInfo, - isPackageEnabled = !packageInfo.isInstalled || packageEnabled(item.app.packageName) + packageInfo = packageState.packageInfo, + isPackageEnabled = packageState.isEnabled ) }.toList() } diff --git a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt index 59447945..4b142b89 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt @@ -3,7 +3,6 @@ package com.anod.appwatcher.watchlist import android.app.Application import android.content.ComponentName import android.content.Intent -import android.content.pm.PackageManager import android.graphics.drawable.AdaptiveIconDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.Icon @@ -30,18 +29,17 @@ import com.anod.appwatcher.navigation.SceneNavKey import com.anod.appwatcher.sync.SyncScheduler import com.anod.appwatcher.utils.BaseFlowViewModel import com.anod.appwatcher.utils.PackageChangedReceiver +import com.anod.appwatcher.utils.PackageStateCache import com.anod.appwatcher.utils.SyncProgress import com.anod.appwatcher.utils.appScope import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import com.anod.appwatcher.utils.color.MaterialColors import com.anod.appwatcher.utils.forMyApps import com.anod.appwatcher.utils.getInt -import com.anod.appwatcher.utils.isPackageEnabled import com.anod.appwatcher.utils.networkConnection import com.anod.appwatcher.utils.prefs import com.anod.appwatcher.utils.syncProgressFlow import info.anodsplace.applog.AppLog -import info.anodsplace.framework.content.InstalledApps import info.anodsplace.framework.content.PinShortcut import info.anodsplace.framework.content.PinShortcutManager import info.anodsplace.framework.content.ScreenCommonAction @@ -140,11 +138,9 @@ class WatchListStateViewModel( private val db: AppsDatabase by inject() private val packageChangedReceiver: PackageChangedReceiver by inject() private val recentlyInstalledAppsLoader: RecentlyInstalledAppsLoader by inject() - private val packageManager: PackageManager by inject() + private val packageStates: PackageStateCache by inject() private val shortcutManager: PinShortcutManager by inject() - val installedApps = InstalledApps.MemoryCache(InstalledApps.PackageManager(packageManager)) - private var watchListPreferences = WatchListPreferences( defaultFilterId = defaultFilterId, showOnDeviceApps = showOnDeviceApps, @@ -173,7 +169,7 @@ class WatchListStateViewModel( AppLog.d("[Paging] listPagerFactory: $configKey, return existing ${pagerFactories[configKey].hashCode()}") return pagerFactories[configKey]!! } - pagerFactories[configKey] = AppsWatchListPagerFactory(pagingSourceConfig, installedApps = installedApps, viewModelScope) + pagerFactories[configKey] = AppsWatchListPagerFactory(pagingSourceConfig, packageStates, viewModelScope) AppLog.d("[Paging] listPagerFactory: $configKey, create new ${pagerFactories[configKey].hashCode()}") return pagerFactories[configKey]!! } @@ -216,8 +212,7 @@ class WatchListStateViewModel( viewModelScope.launch { val clearedDisabledUpdates = db.apps().clearDisabledUpdateStatuses( - installedApps = installedApps, - packageEnabled = packageManager::isPackageEnabled + packageStates = packageStates ) if (clearedDisabledUpdates > 0) { invalidatePagingSources() @@ -279,7 +274,6 @@ class WatchListStateViewModel( viewModelScope.launch { packageChangedReceiver.observer.collect { - installedApps.reset() invalidatePagingSources() } } @@ -432,7 +426,7 @@ class WatchListStateViewModel( throw IllegalStateException("auth token is invalid") } - installedApps.reset() + packageStates.clear() invalidatePagingSources() viewState = viewState.copy( syncProgress = SyncProgress(true, 0), diff --git a/app/src/test/java/com/anod/appwatcher/installed/PackageRemovedReceiverTest.kt b/app/src/test/java/com/anod/appwatcher/installed/PackageRemovedReceiverTest.kt new file mode 100644 index 00000000..3849c7f9 --- /dev/null +++ b/app/src/test/java/com/anod/appwatcher/installed/PackageRemovedReceiverTest.kt @@ -0,0 +1,38 @@ +package com.anod.appwatcher.installed + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PackageRemovedReceiverTest { + + @Test + fun applicationPackageChangeIsRelevant() { + assertTrue( + isApplicationPackageChange( + packageName = "com.example", + changedComponents = arrayOf("com.example") + ) + ) + } + + @Test + fun componentOnlyPackageChangeIsIgnored() { + assertFalse( + isApplicationPackageChange( + packageName = "com.example", + changedComponents = arrayOf("com.example.SyncReceiver") + ) + ) + } + + @Test + fun packageChangeWithoutComponentListIsRelevant() { + assertTrue( + isApplicationPackageChange( + packageName = "com.example", + changedComponents = null + ) + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt b/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt index 5b4de87f..b0f30cb0 100644 --- a/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt +++ b/app/src/test/java/com/anod/appwatcher/utils/PackageEnabledStateTest.kt @@ -5,6 +5,7 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -78,6 +79,51 @@ class PackageEnabledStateTest { assertFalse(context.packageManager.isPackageEnabled(packageName)) } + @Test + fun packageStateCacheRefreshesOnlyChangedState() = runBlocking { + val packageName = context.packageName + val cache = PackageStateCache(context.packageManager) + + val initialStates = cache.load(listOf(packageName)) + assertTrue(initialStates.getValue(packageName).isEnabled) + assertFalse(cache.refresh(packageName).changed) + + context.packageManager.setApplicationEnabledSetting( + packageName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED, + PackageManager.DONT_KILL_APP + ) + val refresh = cache.refresh(packageName) + + assertTrue(refresh.changed) + assertFalse(refresh.current.isEnabled) + assertFalse(cache.load(listOf(packageName)).getValue(packageName).isEnabled) + } + + @Test + fun packageStateCacheLoadsRequestedPackagesMissingFromInitialSnapshot() = runBlocking { + val initialPackageName = context.packageName + val additionalPackageName = "cached.additional.state.test" + installPackage(additionalPackageName, manifestEnabled = true) + val cache = PackageStateCache(context.packageManager) + + cache.load(listOf(initialPackageName)) + val updatedStates = cache.load(listOf(additionalPackageName)) + + assertTrue(updatedStates.getValue(initialPackageName).isEnabled) + assertTrue(updatedStates.getValue(additionalPackageName).isEnabled) + } + + @Test + fun packageStateCacheStoresNotInstalledRequestedPackages() = runBlocking { + val packageName = "cached.not.installed.test" + val cache = PackageStateCache(context.packageManager) + + val states = cache.load(listOf(packageName)) + + assertFalse(states.getValue(packageName).isInstalled) + } + private fun installPackage(packageName: String, manifestEnabled: Boolean) { shadowOf(context.packageManager).installPackage( PackageInfo().apply { diff --git a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt index 85ad6f14..938625b4 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt @@ -12,6 +12,8 @@ import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.Price import com.anod.appwatcher.model.Filters import com.anod.appwatcher.preferences.Preferences +import com.anod.appwatcher.utils.PackageState +import com.anod.appwatcher.utils.PackageStateProvider import com.anod.appwatcher.utils.clearDisabledUpdateStatus import com.anod.appwatcher.utils.clearDisabledUpdateStatuses import info.anodsplace.framework.content.InstalledApps @@ -154,8 +156,10 @@ class WatchListPagingSourceRoomTest { ) val cleared = db.apps().clearDisabledUpdateStatuses( - installedApps = installedApps, - packageEnabled = { false } + packageStates = packageStateProvider( + installedApps = installedApps, + packageEnabled = { false } + ) ) assertEquals(1, cleared) @@ -209,8 +213,10 @@ class WatchListPagingSourceRoomTest { val cleared = db.apps().clearDisabledUpdateStatus( packageName = "disabled.watched", - installedApps = installedApps, - packageEnabled = { false } + packageState = PackageState( + packageInfo = installedApps.packageInfo("disabled.watched"), + isEnabled = false + ) ) assertEquals(1, cleared) @@ -539,10 +545,22 @@ class WatchListPagingSourceRoomTest { prefs = preferences, packageManager = context.packageManager, database = db, - packageEnabled = packageEnabled, - installedApps = installedApps + packageStates = packageStateProvider(installedApps, packageEnabled) ) + private fun packageStateProvider( + installedApps: InstalledApps, + packageEnabled: (String) -> Boolean + ) = PackageStateProvider { packageNames -> + packageNames.associateWith { packageName -> + val packageInfo = installedApps.packageInfo(packageName) + PackageState( + packageInfo = packageInfo, + isEnabled = !packageInfo.isInstalled || packageEnabled(packageName) + ) + } + } + private fun defaultInstalledApps(): InstalledApps = InstalledApps.StaticMap( mapOf( "local.only.watched" to InstalledApps.Info(versionCode = 1, versionName = "1"), From 5ea7bed1906f0fc334c80bf5e5ccdf785d59a6f6 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:42:57 +0300 Subject: [PATCH 4/4] Clarify package change handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../appwatcher/installed/PackageRemovedReceiver.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt index de61c9a9..9c824fa4 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt @@ -20,23 +20,23 @@ class PackageRemovedReceiver : BroadcastReceiver(), KoinComponent { val packageName = intent.data?.schemeSpecificPart ?: return when (action) { Intent.ACTION_PACKAGE_FULLY_REMOVED -> { - notify(packageName, clearDisabledUpdates = false, requireStateChange = false) + handlePackageChange(packageName, clearDisabledUpdates = false, requireStateChange = false) } Intent.ACTION_PACKAGE_ADDED -> { - notify(packageName, clearDisabledUpdates = true, requireStateChange = false) + handlePackageChange(packageName, clearDisabledUpdates = true, requireStateChange = false) } Intent.ACTION_PACKAGE_CHANGED -> { if (isApplicationPackageChange(packageName, intent.getStringArrayExtra(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST))) { - notify(packageName, clearDisabledUpdates = true, requireStateChange = true) + handlePackageChange(packageName, clearDisabledUpdates = true, requireStateChange = true) } } Intent.ACTION_PACKAGE_REPLACED -> { - notify(packageName, clearDisabledUpdates = true, requireStateChange = false) + handlePackageChange(packageName, clearDisabledUpdates = true, requireStateChange = false) } } } - private fun notify( + private fun handlePackageChange( packageName: String, clearDisabledUpdates: Boolean, requireStateChange: Boolean