From 8f5acae4d70234d5da1e45d2e7205a6432babe16 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:35:27 +0300 Subject: [PATCH 1/2] Notify updates for disabled installed apps Keep STATUS_UPDATED for sleeping/disabled apps so new releases are notified once, while UI keeps showing disabled status via existing isPackageEnabled gating. Gate notification Update action on enabled installs and remove disabled-status clearing on startup, sync, and package broadcasts. Fixes #171 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../anod/appwatcher/database/AppListTable.kt | 22 ---- .../installed/PackageRemovedReceiver.kt | 23 +--- .../preferences/SettingsViewModel.kt | 1 + .../anod/appwatcher/sync/SyncNotification.kt | 4 +- .../com/anod/appwatcher/sync/UpdateCheck.kt | 36 +----- .../com/anod/appwatcher/sync/UpdatedApp.kt | 13 +- .../appwatcher/utils/PackageEnabledState.kt | 35 +----- .../watchlist/WatchListStateViewModel.kt | 11 -- .../appwatcher/details/AppItemStateTest.kt | 1 + .../sync/UpdateCheckVersionRollbackTest.kt | 44 +++---- .../utils/SyncNotificationFilterTest.kt | 19 ++- .../WatchListPagingSourceRoomTest.kt | 112 +++++++++--------- 12 files changed, 116 insertions(+), 205 deletions(-) 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 301c7026..db7db131 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt @@ -90,12 +90,6 @@ 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" @@ -152,22 +146,6 @@ 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}, " + - "${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, " + 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 9c824fa4..489e3f56 100644 --- a/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt +++ b/app/src/main/java/com/anod/appwatcher/installed/PackageRemovedReceiver.kt @@ -4,11 +4,9 @@ package com.anod.appwatcher.installed import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -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 kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -19,38 +17,27 @@ class PackageRemovedReceiver : BroadcastReceiver(), KoinComponent { val action = intent?.action ?: return val packageName = intent.data?.schemeSpecificPart ?: return when (action) { - Intent.ACTION_PACKAGE_FULLY_REMOVED -> { - handlePackageChange(packageName, clearDisabledUpdates = false, requireStateChange = false) - } - Intent.ACTION_PACKAGE_ADDED -> { - handlePackageChange(packageName, clearDisabledUpdates = true, requireStateChange = false) + Intent.ACTION_PACKAGE_FULLY_REMOVED, + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_REPLACED -> { + handlePackageChange(packageName, requireStateChange = false) } Intent.ACTION_PACKAGE_CHANGED -> { if (isApplicationPackageChange(packageName, intent.getStringArrayExtra(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST))) { - handlePackageChange(packageName, clearDisabledUpdates = true, requireStateChange = true) + handlePackageChange(packageName, requireStateChange = true) } } - Intent.ACTION_PACKAGE_REPLACED -> { - handlePackageChange(packageName, clearDisabledUpdates = true, requireStateChange = false) - } } } private fun handlePackageChange( packageName: String, - clearDisabledUpdates: Boolean, requireStateChange: Boolean ) { val pendingResult = goAsync() appScope.launch { try { val packageStateRefresh = get().refresh(packageName) - if (clearDisabledUpdates) { - get().apps().clearDisabledUpdateStatus( - packageName = packageName, - packageState = packageStateRefresh.current - ) - } if (!requireStateChange || packageStateRefresh.changed) { get().emit(packageName + ":" + System.currentTimeMillis()) } diff --git a/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt b/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt index 624e1f42..d5e6d89b 100644 --- a/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/preferences/SettingsViewModel.kt @@ -367,6 +367,7 @@ class SettingsViewModel : BaseFlowViewModel, builder: NotificationCompat.Builder) { - updatedApps.firstOrNull { it.installedVersionCode > 0 && it.versionNumber > it.installedVersionCode } + updatedApps.firstOrNull { it.canUpdateOnDevice && it.versionNumber > it.installedVersionCode } ?: return val bigText = updatedApps.joinToString(",\n") { it.title } @@ -165,7 +165,7 @@ class SyncNotification(private val context: ApplicationContext, private val noti PendingIntent.getActivity(context.actual, 0, playIntent, PendingIntent.FLAG_IMMUTABLE) ) - if (update.installedVersionCode > 0) { + if (update.canUpdateOnDevice) { val updateIntent = NotificationActivity.intent( Uri.parse("com.anod.appwatcher://play/myapps/1"), NotificationActivity.ACTION_MY_APPS, 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 7ca8ee12..66234114 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/UpdateCheck.kt @@ -29,7 +29,6 @@ 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 @@ -250,15 +249,6 @@ class UpdateCheck( syncId: Long, verboseDiagnostics: Boolean ): SyncResult { - val clearedDisabledUpdates = database.apps().clearDisabledUpdateStatuses( - packageStates = packageStates - ) - 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) { @@ -658,8 +648,7 @@ class UpdateCheck( cachedVersion = localApp.versionNumber, installedVersion = installedInfo.versionCode, status = localApp.status, - lastUpdatesViewed = lastUpdatesViewed, - installedEnabled = installedEnabled + lastUpdatesViewed = lastUpdatesViewed ) if (decision == AppUpdateDecision.MARK_UPDATED) { AppLog.i( @@ -678,7 +667,7 @@ class UpdateCheck( val recentChanges = releaseDoc.appDetails.recentChangesHtml ?: "" return AppUpdateResult( values = newApp.contentValues, - updatedApp = UpdatedApp(newApp, recentChanges, installedInfo.versionCode, true), + updatedApp = UpdatedApp(newApp, recentChanges, installedInfo.versionCode, installedEnabled, true), persistChangelog = true, installedVersion = installedInfo.versionCode, installedEnabled = installedEnabled, @@ -691,14 +680,13 @@ 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) } AppUpdateDecision.KEEP_UPDATED -> { val recentChanges = releaseDoc.appDetails.recentChangesHtml ?: "" - updatedApp = UpdatedApp(localApp, recentChanges, installedInfo.versionCode, false) + updatedApp = UpdatedApp(localApp, recentChanges, installedInfo.versionCode, installedEnabled, false) } AppUpdateDecision.MISSING_RESPONSE_KEEP, AppUpdateDecision.RESPONSE_WITHOUT_DETAILS_KEEP, @@ -708,7 +696,6 @@ class UpdateCheck( AppUpdateDecision.MARK_UPDATED, AppUpdateDecision.REFRESH_INSTALLED_CURRENT, AppUpdateDecision.KEEP_DEVICE_UPDATE, - AppUpdateDecision.DISABLED_INSTALLED, AppUpdateDecision.CURRENT -> { } } @@ -920,10 +907,8 @@ 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 @@ -932,16 +917,14 @@ internal enum class AppUpdateDecision(val value: String) { this == UNAVAILABLE_SUPPRESSED || this == UNAVAILABLE_ROLLBACK || this == KEEP_DEVICE_UPDATE || - this == KEEP_UPDATED || - this == DISABLED_INSTALLED + this == KEEP_UPDATED } 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"), - DISABLED_UPDATE_CLEARED("disabled-update-cleared") + INSTALLED_UPDATE_CLEARED("installed-update-cleared") } internal fun detectSyncDecisionSignal( @@ -955,8 +938,6 @@ 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 } @@ -965,16 +946,11 @@ internal fun selectAppUpdateDecision( cachedVersion: Int, installedVersion: Int, status: Int, - lastUpdatesViewed: Boolean, - installedEnabled: Boolean + lastUpdatesViewed: 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/sync/UpdatedApp.kt b/app/src/main/java/com/anod/appwatcher/sync/UpdatedApp.kt index bd032b3e..281b77aa 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/UpdatedApp.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/UpdatedApp.kt @@ -11,10 +11,17 @@ data class UpdatedApp( val uploadDate: String, val recentChanges: String, val installedVersionCode: Int, + val installedEnabled: Boolean, val isNewUpdate: Boolean, val noNewDetails: Boolean = false ) { - constructor(appInfo: App, recentChanges: String, installedVersionCode: Int, isNewUpdate: Boolean) : this( + constructor( + appInfo: App, + recentChanges: String, + installedVersionCode: Int, + installedEnabled: Boolean, + isNewUpdate: Boolean + ) : this( appInfo.packageName, appInfo.versionNumber, appInfo.title, @@ -22,6 +29,10 @@ data class UpdatedApp( appInfo.uploadDate, recentChanges, installedVersionCode, + installedEnabled, isNewUpdate ) + + val canUpdateOnDevice: Boolean + get() = installedVersionCode > 0 && installedEnabled } \ No newline at end of file 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 684ff682..6438ee98 100644 --- a/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt +++ b/app/src/main/java/com/anod/appwatcher/utils/PackageEnabledState.kt @@ -3,7 +3,6 @@ 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 @@ -183,36 +182,4 @@ class PackageStateCache(private val packageManager: PackageManager) : PackageSta } internal fun Map.stateFor(packageName: String): PackageState = - get(packageName) ?: PackageState.NotInstalled - -internal suspend fun AppListTable.clearDisabledUpdateStatus( - packageName: String, - packageState: PackageState -): Int = withContext(Dispatchers.IO) { - if (packageState.isInstalled && !packageState.isEnabled) { - clearUpdateStatusByPackageName(packageName) - } else { - 0 - } -} - -internal suspend fun AppListTable.clearDisabledUpdateStatuses( - packageStates: PackageStateProvider -): Int = withContext(Dispatchers.IO) { - 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 { - packageState.isInstalled && !packageState.isEnabled - } - } - var cleared = 0 - for (rowIds in disabledRowIds.chunked(998)) { - cleared += clearUpdateStatuses(rowIds) - } - cleared -} \ No newline at end of file + get(packageName) ?: PackageState.NotInstalled \ No newline at end of file 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 4b142b89..61dbebe8 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListStateViewModel.kt @@ -32,7 +32,6 @@ 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 @@ -210,16 +209,6 @@ class WatchListStateViewModel( AppLog.d("Initial state: viewState") - viewModelScope.launch { - val clearedDisabledUpdates = db.apps().clearDisabledUpdateStatuses( - packageStates = packageStates - ) - if (clearedDisabledUpdates > 0) { - invalidatePagingSources() - viewState = viewState.copy(dbAppsChange = viewState.dbAppsChange + 1) - } - } - viewModelScope.launch { syncProgressFlow(application).collect { handleEvent(WatchListEvent.UpdateSyncProgress(syncProgress = it)) diff --git a/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt b/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt index 8304cd60..ea5563b2 100644 --- a/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt +++ b/app/src/test/java/com/anod/appwatcher/details/AppItemStateTest.kt @@ -33,6 +33,7 @@ class AppItemStateTest { assertTrue(state.installed) assertFalse(state.showRecent) + assertEquals(Color.Black, state.color) assertEquals( context.getString(R.string.installed_disabled_version, "1.0", 100), state.text 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 1b24a5ce..67cadcb6 100644 --- a/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt +++ b/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt @@ -59,8 +59,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 100, installedVersion = 101, status = App.STATUS_NORMAL, - lastUpdatesViewed = false, - installedEnabled = true + lastUpdatesViewed = false ) ) } @@ -74,8 +73,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 100, status = App.STATUS_NORMAL, - lastUpdatesViewed = true, - installedEnabled = true + lastUpdatesViewed = true ) ) } @@ -89,8 +87,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 100, status = App.STATUS_UPDATED, - lastUpdatesViewed = true, - installedEnabled = true + lastUpdatesViewed = true ) ) } @@ -104,8 +101,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 101, status = App.STATUS_UPDATED, - lastUpdatesViewed = false, - installedEnabled = true + lastUpdatesViewed = false ) ) } @@ -119,8 +115,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 0, status = App.STATUS_UPDATED, - lastUpdatesViewed = true, - installedEnabled = false + lastUpdatesViewed = true ) ) } @@ -134,8 +129,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 0, status = App.STATUS_UPDATED, - lastUpdatesViewed = false, - installedEnabled = false + lastUpdatesViewed = false ) ) } @@ -149,38 +143,36 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 101, installedVersion = 101, status = App.STATUS_NORMAL, - lastUpdatesViewed = true, - installedEnabled = true + lastUpdatesViewed = true ) ) } @Test - fun disabledInstalledAppDoesNotMarkNewVersionAsUpdate() { + fun newVersionOfSleepingInstalledAppMarksUpdate() { + // Enabled state is not a sync input: disabled apps are notified and hidden from update UI. assertEquals( - AppUpdateDecision.DISABLED_INSTALLED, + AppUpdateDecision.MARK_UPDATED, selectAppUpdateDecision( remoteVersion = 101, cachedVersion = 100, installedVersion = 100, status = App.STATUS_NORMAL, - lastUpdatesViewed = false, - installedEnabled = false + lastUpdatesViewed = false ) ) } @Test - fun disabledInstalledAppClearsExistingUpdate() { + fun previouslySuppressedSleepingAppUpdateIsRestoredWithoutNotification() { assertEquals( - AppUpdateDecision.CLEAR_DISABLED_UPDATE, + AppUpdateDecision.RESTORE_DEVICE_UPDATE, selectAppUpdateDecision( remoteVersion = 101, cachedVersion = 101, installedVersion = 100, - status = App.STATUS_UPDATED, - lastUpdatesViewed = false, - installedEnabled = false + status = App.STATUS_NORMAL, + lastUpdatesViewed = false ) ) } @@ -194,8 +186,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 100, installedVersion = 100, status = App.STATUS_NORMAL, - lastUpdatesViewed = false, - installedEnabled = true + lastUpdatesViewed = false ) ) } @@ -209,8 +200,7 @@ class UpdateCheckVersionRollbackTest { cachedVersion = 100, installedVersion = 0, status = App.STATUS_NORMAL, - lastUpdatesViewed = false, - installedEnabled = false + lastUpdatesViewed = false ) ) } diff --git a/app/src/test/java/com/anod/appwatcher/utils/SyncNotificationFilterTest.kt b/app/src/test/java/com/anod/appwatcher/utils/SyncNotificationFilterTest.kt index 1c59bdba..47bd1d21 100644 --- a/app/src/test/java/com/anod/appwatcher/utils/SyncNotificationFilterTest.kt +++ b/app/src/test/java/com/anod/appwatcher/utils/SyncNotificationFilterTest.kt @@ -94,11 +94,27 @@ class SyncNotificationFilterTest { ), actual) } + @Test + fun disabledInstalledAppIsNotifiedWithoutDeviceUpdate() { + val filter = SyncNotification.Filter( + filterInstalled = false, + filterInstalledUpToDate = true, + filterNoChanges = false) + val disabled = updatedApp(1, installedVersionCode = 1, versionNumber = 2, installedEnabled = false) + val enabled = updatedApp(2, installedVersionCode = 1, versionNumber = 2, installedEnabled = true) + + assertEquals(listOf(disabled, enabled), filter.apply(listOf(disabled, enabled))) + assertFalse(disabled.canUpdateOnDevice) + assertTrue(enabled.canUpdateOnDevice) + assertFalse(updatedApp(3, installedVersionCode = 0, installedEnabled = true).canUpdateOnDevice) + } + private fun updatedApp( id: Int, installedVersionCode: Int = 0, versionNumber: Int = 0, - noNewDetails: Boolean = false + noNewDetails: Boolean = false, + installedEnabled: Boolean = installedVersionCode > 0 ) = UpdatedApp( packageName = "item-$id", versionNumber = versionNumber, @@ -107,6 +123,7 @@ class SyncNotificationFilterTest { uploadDate = "100", recentChanges = "", installedVersionCode = installedVersionCode, + installedEnabled = installedEnabled, isNewUpdate = false, noNewDetails = noNewDetails ) 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 938625b4..9fb36dd9 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt @@ -14,8 +14,6 @@ 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 import info.anodsplace.notification.NotificationManager import kotlinx.coroutines.CoroutineScope @@ -140,7 +138,7 @@ class WatchListPagingSourceRoomTest { } @Test - fun disabledUpdateStatusIsClearedBeforePackageIsReenabled() = runBlocking { + fun sleepingAppUpdateIsKeptButShownAsDisabledUntilReenabled() = runBlocking { insertApp( appId = "disabled", packageName = "disabled.watched", @@ -155,73 +153,61 @@ class WatchListPagingSourceRoomTest { ) ) - val cleared = db.apps().clearDisabledUpdateStatuses( - packageStates = packageStateProvider( - installedApps = installedApps, - packageEnabled = { false } + val disabledItem = loadSingleApp( + createPagingSource( + showOnDevice = false, + showRecentlyDiscovered = true, + packageEnabled = { false }, + installedApps = installedApps ) ) - 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) + assertFalse(disabledItem.isPackageEnabled) + assertEquals(App.STATUS_UPDATED, disabledItem.appListItem.app.status) assertEquals( SectionHeader.Watching, - DefaultSectionHeaderFactory(showRecentlyDiscovered = false) - .insertSeparator(before = null, after = appItem) + DefaultSectionHeaderFactory(showRecentlyDiscovered = true) + .insertSeparator(before = null, after = disabledItem) ?.type ) - } - - @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") + assertTrue( + loadApps( + createPagingSource( + showOnDevice = false, + filterId = Filters.UPDATABLE, + packageEnabled = { false }, + installedApps = installedApps + ) + ).isEmpty() + ) + assertEquals(App.STATUS_UPDATED, db.apps().loadApp("disabled")!!.status) + + val enabledItem = loadSingleApp( + createPagingSource( + showOnDevice = false, + showRecentlyDiscovered = true, + packageEnabled = { true }, + installedApps = installedApps ) ) - val cleared = db.apps().clearDisabledUpdateStatus( - packageName = "disabled.watched", - packageState = PackageState( - packageInfo = installedApps.packageInfo("disabled.watched"), - isEnabled = false - ) + assertEquals( + SectionHeader.New, + DefaultSectionHeaderFactory(showRecentlyDiscovered = true) + .insertSeparator(before = null, after = enabledItem) + ?.type + ) + assertEquals( + listOf("disabled.watched"), + loadApps( + createPagingSource( + showOnDevice = false, + filterId = Filters.UPDATABLE, + packageEnabled = { true }, + installedApps = installedApps + ) + ).map { it.appListItem.app.packageName } ) - - assertEquals(1, cleared) - assertEquals(App.STATUS_NORMAL, db.apps().loadApp("disabled")!!.status) - assertEquals(App.STATUS_UPDATED, db.apps().loadApp("other")!!.status) } @Test @@ -548,6 +534,14 @@ class WatchListPagingSourceRoomTest { packageStates = packageStateProvider(installedApps, packageEnabled) ) + private suspend fun loadApps(pagingSource: WatchListPagingSource): List = + (pagingSource.load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)) as PagingSource.LoadResult.Page) + .data + .filterIsInstance() + + private suspend fun loadSingleApp(pagingSource: WatchListPagingSource): SectionItem.App = + loadApps(pagingSource).single() + private fun packageStateProvider( installedApps: InstalledApps, packageEnabled: (String) -> Boolean From 5c9de6d4f897e187d5c3789a49b4b1c019744cfb Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:50:53 +0300 Subject: [PATCH 2/2] Keep multi-app notification details without device update Only gate the Update action on canUpdateOnDevice so notifications for disabled apps still list app titles and offer Dismiss. Cover repeat notification prevention for marked sleeping app updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../anod/appwatcher/sync/SyncNotification.kt | 19 +++++++++---------- .../sync/UpdateCheckVersionRollbackTest.kt | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt b/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt index a56c4ab6..06a49f8a 100644 --- a/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt +++ b/app/src/main/java/com/anod/appwatcher/sync/SyncNotification.kt @@ -124,19 +124,18 @@ class SyncNotification(private val context: ApplicationContext, private val noti } private fun addMultipleExtraInfo(updatedApps: List, builder: NotificationCompat.Builder) { - updatedApps.firstOrNull { it.canUpdateOnDevice && it.versionNumber > it.installedVersionCode } - ?: return - val bigText = updatedApps.joinToString(",\n") { it.title } builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) - val updateIntent = NotificationActivity.intent( - Uri.parse("com.anod.appwatcher://play/myapps/1"), - NotificationActivity.ACTION_MY_APPS, - context.actual) - builder.addAction(R.drawable.ic_system_update_alt_white_24dp, context.getString(R.string.noti_action_update), - PendingIntent.getActivity(context.actual, 0, updateIntent, PendingIntent.FLAG_IMMUTABLE) - ) + if (updatedApps.any { it.canUpdateOnDevice && it.versionNumber > it.installedVersionCode }) { + val updateIntent = NotificationActivity.intent( + Uri.parse("com.anod.appwatcher://play/myapps/1"), + NotificationActivity.ACTION_MY_APPS, + context.actual) + builder.addAction(R.drawable.ic_system_update_alt_white_24dp, context.getString(R.string.noti_action_update), + PendingIntent.getActivity(context.actual, 0, updateIntent, PendingIntent.FLAG_IMMUTABLE) + ) + } val readIntent = NotificationActivity.intent( Uri.parse("com.anod.appwatcher://dismiss/"), 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 67cadcb6..ec05912c 100644 --- a/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt +++ b/app/src/test/java/com/anod/appwatcher/sync/UpdateCheckVersionRollbackTest.kt @@ -163,6 +163,20 @@ class UpdateCheckVersionRollbackTest { ) } + @Test + fun markedSleepingAppUpdateIsKeptOnNextSyncWithoutRepeatNotification() { + assertEquals( + AppUpdateDecision.KEEP_DEVICE_UPDATE, + selectAppUpdateDecision( + remoteVersion = 101, + cachedVersion = 101, + installedVersion = 100, + status = App.STATUS_UPDATED, + lastUpdatesViewed = true + ) + ) + } + @Test fun previouslySuppressedSleepingAppUpdateIsRestoredWithoutNotification() { assertEquals(