From de06f77df4d34719ae2e86326ba5521b3b45607d Mon Sep 17 00:00:00 2001 From: Alexandr Gavrishev Date: Wed, 10 Jun 2026 22:15:52 +0300 Subject: [PATCH] Stabilize watchlist paging during sync updates Capture a stable app row-id snapshot per WatchListPagingSource generation and page by those row IDs so status changes during sync cannot move the same app across offset windows and emit duplicate Lazy keys. Add regression coverage for rows moving across paging offsets while keeping recently-discovered row snapshot ordering valid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/main/baseline-prof.txt | 7 -- .../anod/appwatcher/database/AppListTable.kt | 81 +++++++++++-------- .../watchlist/WatchListPagingSource.kt | 57 ++++++++++--- .../baselineProfiles/baseline-prof.txt | 12 --- .../baselineProfiles/startup-prof.txt | 12 --- .../database/AppListTableQueriesTest.kt | 28 +++++-- .../database/AppListTableRoomTest.kt | 22 ++--- .../WatchListPagingSourceRoomTest.kt | 74 ++++++++++++++++- 8 files changed, 192 insertions(+), 101 deletions(-) diff --git a/app/src/main/baseline-prof.txt b/app/src/main/baseline-prof.txt index c8b3f9c1..795055b3 100644 --- a/app/src/main/baseline-prof.txt +++ b/app/src/main/baseline-prof.txt @@ -11784,10 +11784,7 @@ PLcom/anod/appwatcher/database/AppListTable$DefaultImpls;->loadRowIds(Lcom/anod/ PLcom/anod/appwatcher/database/AppListTable$Queries;->()V PLcom/anod/appwatcher/database/AppListTable$Queries;->()V PLcom/anod/appwatcher/database/AppListTable$Queries;->changes(Lcom/anod/appwatcher/database/AppListTable;)Lkotlinx/coroutines/flow/Flow; -PLcom/anod/appwatcher/database/AppListTable$Queries;->createAppsListQuery(IZLjava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;)Lkotlin/Pair; -PLcom/anod/appwatcher/database/AppListTable$Queries;->createSelection(Ljava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;)Lkotlin/Pair; PLcom/anod/appwatcher/database/AppListTable$Queries;->createSortOrder(IZ)Ljava/lang/String; -PLcom/anod/appwatcher/database/AppListTable$Queries;->loadAppList(IZLjava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; PLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->(Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)V PLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; PLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; @@ -11868,10 +11865,6 @@ PLcom/anod/appwatcher/database/DbContentProvider$Companion;->()V PLcom/anod/appwatcher/database/DbContentProvider$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V PLcom/anod/appwatcher/database/DbContentProvider$dbSchemaManager$2;->(Lcom/anod/appwatcher/database/DbContentProvider;)V PLcom/anod/appwatcher/database/SchedulesTable_Impl;->getRequiredConverters()Ljava/util/List; -PLcom/anod/appwatcher/database/SqlOffset;->()V -PLcom/anod/appwatcher/database/SqlOffset;->(II)V -PLcom/anod/appwatcher/database/SqlOffset;->getLimit()I -PLcom/anod/appwatcher/database/SqlOffset;->getOffset()I PLcom/anod/appwatcher/database/TagsTable_Impl;->-$$Nest$fget__db(Lcom/anod/appwatcher/database/TagsTable_Impl;)Landroidx/room/RoomDatabase; PLcom/anod/appwatcher/database/TagsTable_Impl;->(Landroidx/room/RoomDatabase;)V PLcom/anod/appwatcher/database/TagsTable_Impl;->getRequiredConverters()Ljava/util/List; 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 17a464ea..d6ea03b4 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppListTable.kt @@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -class SqlOffset(val offset: Int, val limit: Int) - @Dao interface AppListTable { @@ -40,7 +38,7 @@ interface AppListTable { suspend fun load(query: SupportSQLiteQuery): List @RawQuery - suspend fun count(query: SupportSQLiteQuery): Int + suspend fun loadRows(query: SupportSQLiteQuery): List @Query("SELECT * FROM $TABLE WHERE ${Columns.APP_ID} == :appId") fun observeApp(appId: String): Flow @@ -185,45 +183,36 @@ interface AppListTable { titleFilter: String, table: AppListTable ): Flow> { - val query = createAppsListQuery(sortId, orderByRecentlyUpdated, tagId, titleFilter, null) + val query = createAppsListQuery(sortId, orderByRecentlyUpdated, tagId, titleFilter) return table.observe(SimpleSQLiteQuery(query.first, query.second)) } - suspend fun loadAppList( + suspend fun loadAppListRows( sortId: Int, orderByRecentlyDiscovered: Boolean, tagId: Int?, titleFilter: String, - offset: SqlOffset?, table: AppListTable - ): List { - val query = createAppsListQuery(sortId, orderByRecentlyDiscovered, tagId, titleFilter, offset) - return table.load(SimpleSQLiteQuery(query.first, query.second)) - } - - suspend fun countAppList(tagId: Int?, titleFilter: String, table: AppListTable): Int { - val query = createAppsListCountQuery(tagId, titleFilter) - return table.count(SimpleSQLiteQuery(query.first, query.second)) + ): List { + val query = createAppsListRowsQuery(sortId, orderByRecentlyDiscovered, tagId, titleFilter) + return table.loadRows(SimpleSQLiteQuery(query.first, query.second)) } - private fun createAppsListCountQuery(tagId: Int?, titleFilter: String): Pair> { - val selection = createSelection(tagId, titleFilter, null) - val sql = - "SELECT COUNT(DISTINCT $TABLE.${BaseColumns._ID}) " + - "FROM $TABLE " + - "WHERE ${selection.first} " - return Pair(sql, selection.second) + suspend fun loadAppList(rowIds: List, table: AppListTable): List { + if (rowIds.isEmpty()) { + return emptyList() + } + val query = createAppsListRowsQuery(rowIds) + return table.load(SimpleSQLiteQuery(query)) } internal fun createAppsListQuery( sortId: Int, orderByRecentlyDiscovered: Boolean, tagId: Int?, - titleFilter: String, - offset: SqlOffset? + titleFilter: String ): Pair> { - val rangeSql = if (offset == null) "" else " LIMIT ? OFFSET ? " - val selection = createSelection(tagId, titleFilter, offset) + val selection = createSelection(tagId, titleFilter) val sql = "SELECT $TABLE.*, ${ChangelogTable.TableColumns.DETAILS}, ${ChangelogTable.TableColumns.NO_NEW_DETAILS}, " + @@ -232,11 +221,38 @@ interface AppListTable { "LEFT JOIN ${ChangelogTable.TABLE} ON ${TableColumns.APP_ID} == ${ChangelogTable.TableColumns.APP_ID} " + "AND ${TableColumns.VERSION_NUMBER} == ${ChangelogTable.TableColumns.VERSION_CODE} " + "WHERE ${selection.first} " + - "ORDER BY ${createSortOrder(sortId, orderByRecentlyDiscovered)} " + - rangeSql + "ORDER BY ${createSortOrder(sortId, orderByRecentlyDiscovered)} " + return Pair(sql, selection.second) + } + + internal fun createAppsListRowsQuery( + sortId: Int, + orderByRecentlyDiscovered: Boolean, + tagId: Int?, + titleFilter: String + ): Pair> { + val selection = createSelection(tagId, titleFilter) + val sql = + "SELECT $TABLE.${BaseColumns._ID} " + + "FROM $TABLE " + + "WHERE ${selection.first} " + + "ORDER BY ${createSortOrder(sortId, orderByRecentlyDiscovered)} " return Pair(sql, selection.second) } + private fun createAppsListRowsQuery(rowIds: List): String { + val rowIdsSql = rowIds.joinToString(",") + val orderSql = rowIds.mapIndexed { index, rowId -> "WHEN $rowId THEN $index" }.joinToString(" ") + return "SELECT $TABLE.*, ${ChangelogTable.TableColumns.DETAILS}, ${ChangelogTable.TableColumns.NO_NEW_DETAILS}, " + + "CASE WHEN ${Columns.SYNC_TIMESTAMP} > $recentTime THEN 1 ELSE 0 END ${Columns.RECENT_FLAG} " + + "FROM $TABLE " + + "LEFT JOIN ${ChangelogTable.TABLE} ON ${TableColumns.APP_ID} == ${ChangelogTable.TableColumns.APP_ID} " + + "AND ${TableColumns.VERSION_NUMBER} == ${ChangelogTable.TableColumns.VERSION_CODE} " + + "WHERE $TABLE.${BaseColumns._ID} IN ($rowIdsSql) " + + "AND ${Columns.STATUS} != ${App.STATUS_DELETED} " + + "ORDER BY CASE $TABLE.${BaseColumns._ID} $orderSql END" + } + suspend fun insert(app: App, db: AppsDatabase): Long = withContext(Dispatchers.IO) { // Skip id to apply autoincrement return@withContext db.runInTransaction(Callable { @@ -263,7 +279,7 @@ interface AppListTable { Columns.STATUS + " DESC" ) if (orderByRecentlyUpdated) { - filter.add(Columns.RECENT_FLAG + " DESC") + filter.add("CASE WHEN ${Columns.SYNC_TIMESTAMP} > $recentTime THEN 1 ELSE 0 END DESC") } when (sortId) { Preferences.SORT_NAME_DESC -> filter.add(Columns.TITLE + " COLLATE NOCASE DESC") @@ -275,9 +291,9 @@ interface AppListTable { return filter.joinToString(", ") } - private fun createSelection(tagId: Int?, titleFilter: String, offset: SqlOffset?): Pair> { + private fun createSelection(tagId: Int?, titleFilter: String): Pair> { val selc = ArrayList(3) - val args = ArrayList(5) + val args = ArrayList(3) selc.add(Columns.STATUS + " != ?") args.add(App.STATUS_DELETED.toString()) @@ -307,11 +323,6 @@ interface AppListTable { args.add("%$titleFilter%") } - if (offset != null) { - args.add(offset.limit.toString()) - args.add(offset.offset.toString()) - } - return Pair(selc.joinToString(" AND "), args.toTypedArray()) } 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 d330c434..e045388c 100644 --- a/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt +++ b/app/src/main/java/com/anod/appwatcher/watchlist/WatchListPagingSource.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Immutable import androidx.paging.PagingState import com.anod.appwatcher.database.AppListTable import com.anod.appwatcher.database.AppsDatabase -import com.anod.appwatcher.database.SqlOffset import com.anod.appwatcher.database.entities.AppListItem import com.anod.appwatcher.database.entities.packageToApp import com.anod.appwatcher.installed.InstalledTaskWorker @@ -16,6 +15,8 @@ import com.anod.appwatcher.preferences.Preferences import info.anodsplace.applog.AppLog import info.anodsplace.framework.content.InstalledApps import kotlin.math.max +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class WatchListPagingSource( private val config: Config, @@ -25,7 +26,15 @@ class WatchListPagingSource( private val installedApps: InstalledApps, ) : FilterablePagingSource() { override var filterQuery: String = "" + set(value) { + if (field != value) { + rowIdSnapshot = null + } + field = value + } private val itemFilter: AppListFilter = createFilter(config.filterId) + private val rowIdSnapshotMutex = Mutex() + private var rowIdSnapshot: List? = null @Immutable data class Config(val filterId: Int, val tagId: Int?, val showRecentlyDiscovered: Boolean, val showOnDevice: Boolean, val showRecentlyInstalled: Boolean,) @@ -53,11 +62,15 @@ class WatchListPagingSource( limit = max(0, limit) } - val data = AppListTable.Queries.loadAppList( - sortId, config.showRecentlyDiscovered, config.tagId, filterQuery, SqlOffset(offset, limit), database.apps() - ) + val rowIds = loadRowIdSnapshot(sortId) + val pageRowIds = if (offset >= rowIds.size) { + emptyList() + } else { + rowIds.subList(offset, minOf(rowIds.size, offset + limit)) + } + val data = AppListTable.Queries.loadAppList(pageRowIds, database.apps()) val filtered = data.filter { !itemFilter.filterRecord(it) } - var totalItems = countTotalItems() + var totalItems = countTotalItems(hasMissingSnapshotRows = data.size < pageRowIds.size) items.addAll(filtered.map { SectionItem.App( @@ -67,7 +80,7 @@ class WatchListPagingSource( ) }) - if (config.showOnDevice && data.size < limit) { + if (config.showOnDevice && pageRowIds.size < limit) { items.addAll(loadOnDeviceItems(sortId)) } @@ -80,7 +93,7 @@ class WatchListPagingSource( key = params.key, offset = offset, loadSize = params.loadSize, - loadedDataSize = data.size, + loadedDataSize = pageRowIds.size, limit = limit ) val itemsBefore = if (totalItems == LoadResult.Page.COUNT_UNDEFINED) { @@ -104,12 +117,34 @@ class WatchListPagingSource( return page } - private suspend fun countTotalItems(): Int { - if (config.filterId != Filters.ALL || config.showOnDevice) { + private fun countTotalItems(hasMissingSnapshotRows: Boolean): Int { + if (config.filterId != Filters.ALL || config.showOnDevice || hasMissingSnapshotRows) { return LoadResult.Page.COUNT_UNDEFINED } - val appsCount = AppListTable.Queries.countAppList(config.tagId, filterQuery, database.apps()) - return appsCount + if (config.showRecentlyInstalled) 1 else 0 + return (rowIdSnapshot?.size ?: 0) + if (config.showRecentlyInstalled) 1 else 0 + } + + private suspend fun loadRowIdSnapshot(sortId: Int): List { + val existingSnapshot = rowIdSnapshot + if (existingSnapshot != null) { + return existingSnapshot + } + return rowIdSnapshotMutex.withLock { + val lockedSnapshot = rowIdSnapshot + if (lockedSnapshot != null) { + lockedSnapshot + } else { + AppListTable.Queries.loadAppListRows( + sortId, + config.showRecentlyDiscovered, + config.tagId, + filterQuery, + database.apps() + ).also { + rowIdSnapshot = it + } + } + } } private suspend fun loadOnDeviceItems(sortId: Int): List { diff --git a/app/src/release/generated/baselineProfiles/baseline-prof.txt b/app/src/release/generated/baselineProfiles/baseline-prof.txt index 2efeff05..29287107 100644 --- a/app/src/release/generated/baselineProfiles/baseline-prof.txt +++ b/app/src/release/generated/baselineProfiles/baseline-prof.txt @@ -25309,12 +25309,7 @@ Lcom/anod/appwatcher/database/AppListTable$Queries; SPLcom/anod/appwatcher/database/AppListTable$Queries;->()V SPLcom/anod/appwatcher/database/AppListTable$Queries;->()V SPLcom/anod/appwatcher/database/AppListTable$Queries;->changes(Lcom/anod/appwatcher/database/AppListTable;)Lkotlinx/coroutines/flow/Flow; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->countAppList(Ljava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->createAppsListCountQuery(Ljava/lang/Integer;Ljava/lang/String;)Lkotlin/Pair; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->createAppsListQuery$app(IZLjava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;)Lkotlin/Pair; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->createSelection(Ljava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;)Lkotlin/Pair; SPLcom/anod/appwatcher/database/AppListTable$Queries;->createSortOrder(IZ)Ljava/lang/String; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->loadAppList(IZLjava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; Lcom/anod/appwatcher/database/AppListTable$loadRowIds$2; SPLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->(Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)V SPLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; @@ -25326,8 +25321,6 @@ SPLcom/anod/appwatcher/database/AppListTable_Impl;->()V SPLcom/anod/appwatcher/database/AppListTable_Impl;->(Landroidx/room/RoomDatabase;)V SPLcom/anod/appwatcher/database/AppListTable_Impl;->_loadRowIds$lambda$0(Ljava/lang/String;Ljava/util/List;Landroidx/sqlite/SQLiteConnection;)Ljava/util/List; SPLcom/anod/appwatcher/database/AppListTable_Impl;->_loadRowIds(Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -SPLcom/anod/appwatcher/database/AppListTable_Impl;->count$lambda$1(Ljava/lang/String;Landroidx/room/RoomRawQuery;Landroidx/sqlite/SQLiteConnection;)I -SPLcom/anod/appwatcher/database/AppListTable_Impl;->count(Landroidx/sqlite/db/SupportSQLiteQuery;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/anod/appwatcher/database/AppListTable_Impl;->load$lambda$0(Ljava/lang/String;Landroidx/room/RoomRawQuery;Landroidx/sqlite/SQLiteConnection;)Ljava/util/List; SPLcom/anod/appwatcher/database/AppListTable_Impl;->load(Landroidx/sqlite/db/SupportSQLiteQuery;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/anod/appwatcher/database/AppListTable_Impl;->loadRowIds(Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -25468,11 +25461,6 @@ Lcom/anod/appwatcher/database/SchedulesTable_Impl$Companion; SPLcom/anod/appwatcher/database/SchedulesTable_Impl$Companion;->()V SPLcom/anod/appwatcher/database/SchedulesTable_Impl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V SPLcom/anod/appwatcher/database/SchedulesTable_Impl$Companion;->getRequiredConverters()Ljava/util/List; -Lcom/anod/appwatcher/database/SqlOffset; -SPLcom/anod/appwatcher/database/SqlOffset;->()V -SPLcom/anod/appwatcher/database/SqlOffset;->(II)V -SPLcom/anod/appwatcher/database/SqlOffset;->getLimit()I -SPLcom/anod/appwatcher/database/SqlOffset;->getOffset()I Lcom/anod/appwatcher/database/TagsTable; Lcom/anod/appwatcher/database/TagsTable_Impl; SPLcom/anod/appwatcher/database/TagsTable_Impl;->()V diff --git a/app/src/release/generated/baselineProfiles/startup-prof.txt b/app/src/release/generated/baselineProfiles/startup-prof.txt index 2efeff05..29287107 100644 --- a/app/src/release/generated/baselineProfiles/startup-prof.txt +++ b/app/src/release/generated/baselineProfiles/startup-prof.txt @@ -25309,12 +25309,7 @@ Lcom/anod/appwatcher/database/AppListTable$Queries; SPLcom/anod/appwatcher/database/AppListTable$Queries;->()V SPLcom/anod/appwatcher/database/AppListTable$Queries;->()V SPLcom/anod/appwatcher/database/AppListTable$Queries;->changes(Lcom/anod/appwatcher/database/AppListTable;)Lkotlinx/coroutines/flow/Flow; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->countAppList(Ljava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->createAppsListCountQuery(Ljava/lang/Integer;Ljava/lang/String;)Lkotlin/Pair; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->createAppsListQuery$app(IZLjava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;)Lkotlin/Pair; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->createSelection(Ljava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;)Lkotlin/Pair; SPLcom/anod/appwatcher/database/AppListTable$Queries;->createSortOrder(IZ)Ljava/lang/String; -SPLcom/anod/appwatcher/database/AppListTable$Queries;->loadAppList(IZLjava/lang/Integer;Ljava/lang/String;Lcom/anod/appwatcher/database/SqlOffset;Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; Lcom/anod/appwatcher/database/AppListTable$loadRowIds$2; SPLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->(Lcom/anod/appwatcher/database/AppListTable;Lkotlin/coroutines/Continuation;)V SPLcom/anod/appwatcher/database/AppListTable$loadRowIds$2;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; @@ -25326,8 +25321,6 @@ SPLcom/anod/appwatcher/database/AppListTable_Impl;->()V SPLcom/anod/appwatcher/database/AppListTable_Impl;->(Landroidx/room/RoomDatabase;)V SPLcom/anod/appwatcher/database/AppListTable_Impl;->_loadRowIds$lambda$0(Ljava/lang/String;Ljava/util/List;Landroidx/sqlite/SQLiteConnection;)Ljava/util/List; SPLcom/anod/appwatcher/database/AppListTable_Impl;->_loadRowIds(Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -SPLcom/anod/appwatcher/database/AppListTable_Impl;->count$lambda$1(Ljava/lang/String;Landroidx/room/RoomRawQuery;Landroidx/sqlite/SQLiteConnection;)I -SPLcom/anod/appwatcher/database/AppListTable_Impl;->count(Landroidx/sqlite/db/SupportSQLiteQuery;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/anod/appwatcher/database/AppListTable_Impl;->load$lambda$0(Ljava/lang/String;Landroidx/room/RoomRawQuery;Landroidx/sqlite/SQLiteConnection;)Ljava/util/List; SPLcom/anod/appwatcher/database/AppListTable_Impl;->load(Landroidx/sqlite/db/SupportSQLiteQuery;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; SPLcom/anod/appwatcher/database/AppListTable_Impl;->loadRowIds(Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -25468,11 +25461,6 @@ Lcom/anod/appwatcher/database/SchedulesTable_Impl$Companion; SPLcom/anod/appwatcher/database/SchedulesTable_Impl$Companion;->()V SPLcom/anod/appwatcher/database/SchedulesTable_Impl$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V SPLcom/anod/appwatcher/database/SchedulesTable_Impl$Companion;->getRequiredConverters()Ljava/util/List; -Lcom/anod/appwatcher/database/SqlOffset; -SPLcom/anod/appwatcher/database/SqlOffset;->()V -SPLcom/anod/appwatcher/database/SqlOffset;->(II)V -SPLcom/anod/appwatcher/database/SqlOffset;->getLimit()I -SPLcom/anod/appwatcher/database/SqlOffset;->getOffset()I Lcom/anod/appwatcher/database/TagsTable; Lcom/anod/appwatcher/database/TagsTable_Impl; SPLcom/anod/appwatcher/database/TagsTable_Impl;->()V 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 ec352273..1845096d 100644 --- a/app/src/test/java/com/anod/appwatcher/database/AppListTableQueriesTest.kt +++ b/app/src/test/java/com/anod/appwatcher/database/AppListTableQueriesTest.kt @@ -16,14 +16,15 @@ class AppListTableQueriesTest { sortId = Preferences.SORT_NAME_ASC, orderByRecentlyDiscovered = false, tagId = null, - titleFilter = "", - offset = SqlOffset(offset = 0, limit = 20) + titleFilter = "" ) assertTrue(sql.contains("LEFT JOIN changelog ON app_list.app_id == changelog.app_id")) assertTrue(sql.contains("AND app_list.ver_num == changelog.code")) assertFalse(sql.contains("SELECT MAX(")) - assertEquals(listOf(App.STATUS_DELETED.toString(), "20", "0"), args.toList()) + assertFalse(sql.contains("LIMIT")) + assertFalse(sql.contains("OFFSET")) + assertEquals(listOf(App.STATUS_DELETED.toString()), args.toList()) } @Test @@ -33,8 +34,7 @@ class AppListTableQueriesTest { sortId = Preferences.SORT_NAME_ASC, orderByRecentlyDiscovered = false, tagId = tagId, - titleFilter = "", - offset = null + titleFilter = "" ) assertFalse(sql.contains("INNER JOIN app_tags")) @@ -42,14 +42,28 @@ class AppListTableQueriesTest { assertEquals(listOf(App.STATUS_DELETED.toString(), tagId.toString()), args.toList()) } + @Test + fun `row snapshot query can order by recently discovered`() { + 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")) + assertEquals(listOf(App.STATUS_DELETED.toString()), args.toList()) + } + @Test fun `untagged list query uses not exists`() { val (sql, args) = AppListTable.Queries.createAppsListQuery( sortId = Preferences.SORT_NAME_ASC, orderByRecentlyDiscovered = false, tagId = Tag.empty.id, - titleFilter = "", - offset = null + titleFilter = "" ) assertFalse(sql.contains("LEFT JOIN app_tags")) 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 1eb663e0..e2bd0760 100644 --- a/app/src/test/java/com/anod/appwatcher/database/AppListTableRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/database/AppListTableRoomTest.kt @@ -52,11 +52,6 @@ class AppListTableRoomTest { assertEquals(listOf("alpha"), loadIds(tagId = null, titleFilter = "Alp")) assertEquals(listOf("beta"), loadIds(tagId = 2, titleFilter = "Bet")) assertEquals(listOf("gamma"), loadIds(tagId = Tag.empty.id, titleFilter = "Gam")) - - assertEquals(3, count(tagId = null, titleFilter = "")) - assertEquals(1, count(tagId = 1, titleFilter = "")) - assertEquals(2, count(tagId = 2, titleFilter = "")) - assertEquals(1, count(tagId = Tag.empty.id, titleFilter = "")) } private suspend fun insertApp(appId: String, title: String, status: Int = App.STATUS_NORMAL) { @@ -96,20 +91,17 @@ class AppListTableRoomTest { ) } - private suspend fun loadIds(tagId: Int?, titleFilter: String): List = - AppListTable.Queries.loadAppList( + private suspend fun loadIds(tagId: Int?, titleFilter: String): List { + val rowIds = AppListTable.Queries.loadAppListRows( sortId = Preferences.SORT_NAME_ASC, orderByRecentlyDiscovered = false, - tagId = tagId, - titleFilter = titleFilter, - offset = null, - table = db.apps() - ).map { item -> item.app.appId } - - private suspend fun count(tagId: Int?, titleFilter: String): Int = - AppListTable.Queries.countAppList( tagId = tagId, titleFilter = titleFilter, table = db.apps() ) + return AppListTable.Queries.loadAppList( + rowIds = rowIds, + table = db.apps() + ).map { item -> item.app.appId } + } } \ No newline at end of file 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 108a8fdf..e164037d 100644 --- a/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt +++ b/app/src/test/java/com/anod/appwatcher/watchlist/WatchListPagingSourceRoomTest.kt @@ -95,6 +95,71 @@ class WatchListPagingSourceRoomTest { assertEquals(null, secondPage.nextKey) } + @Test + fun pagingSourceKeepsStableRowsWhenStatusChangesMoveItemsAcrossOffsets() = runBlocking { + repeat(10) { index -> + insertApp( + appId = "updated-$index", + packageName = "updated.$index", + title = "Zzz Moved Later $index", + status = App.STATUS_UPDATED + ) + } + repeat(100) { index -> + insertApp(appId = "normal-$index", packageName = "normal.$index", title = "Normal $index") + } + val pagingSource = createPagingSource(showOnDevice = false) + + val firstResult = pagingSource.load(PagingSource.LoadParams.Refresh(key = null, loadSize = 60, placeholdersEnabled = false)) + val firstPage = firstResult as PagingSource.LoadResult.Page + firstPage.data + .filterIsInstance() + .filter { it.appListItem.app.status == App.STATUS_UPDATED } + .take(5) + .forEach { + db.apps().updateStatus(it.appListItem.app.rowId, App.STATUS_NORMAL) + } + + val pages = mutableListOf(firstPage) + var nextKey = firstPage.nextKey + while (nextKey != null) { + val result = pagingSource.load(PagingSource.LoadParams.Append(key = nextKey, loadSize = 20, placeholdersEnabled = false)) + val page = result as PagingSource.LoadResult.Page + pages.add(page) + nextKey = page.nextKey + } + val items = pages.flatMap { it.data }.filterIsInstance() + + assertEquals(110, items.size) + assertEquals(items.map { it.sectionKey }.toSet().size, items.size) + } + + @Test + fun pagingSourceDoesNotRenderRowsDeletedAfterSnapshot() = runBlocking { + repeat(40) { index -> + insertApp(appId = "app-$index", packageName = "app.$index", title = "App $index") + } + val pagingSource = createPagingSource(showOnDevice = false) + + val firstResult = pagingSource.load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)) + 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() + )[20] + db.apps().updateStatus(secondPageRow, App.STATUS_DELETED) + + 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 == secondPageRow }) + assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, secondPage.itemsBefore) + assertEquals(PagingSource.LoadResult.Page.COUNT_UNDEFINED, secondPage.itemsAfter) + } + private fun createPagingSource(showOnDevice: Boolean) = WatchListPagingSource( config = WatchListPagingSource.Config( filterId = Filters.ALL, @@ -119,7 +184,12 @@ class WatchListPagingSourceRoomTest { ) ) - private suspend fun insertApp(appId: String, packageName: String, title: String) { + private suspend fun insertApp( + appId: String, + packageName: String, + title: String, + status: Int = App.STATUS_NORMAL + ) { AppListTable.Queries.insert( App( rowId = 0, @@ -130,7 +200,7 @@ class WatchListPagingSourceRoomTest { title = title, creator = "creator", iconUrl = "", - status = App.STATUS_NORMAL, + status = status, uploadDate = "", price = Price(text = "", cur = "", micros = 0), detailsUrl = null,