Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/AppDatabase.kt +21 −2 Original line number Diff line number Diff line Loading @@ -28,8 +28,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase import earth.maps.cardinal.data.DownloadStatusConverter @Database( entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class], version = 14, entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class, FavoriteSyncRecord::class], version = 15, exportSchema = false ) @TypeConverters(TileTypeConverter::class, DownloadStatusConverter::class, ItemTypeConverter::class) Loading @@ -41,6 +41,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun savedPlaceDao(): SavedPlaceDao abstract fun listItemDao(): ListItemDao abstract fun recentSearchDao(): RecentSearchDao abstract fun favoriteSyncRecordDao(): FavoriteSyncRecordDao companion object { @Volatile Loading Loading @@ -243,6 +244,23 @@ abstract class AppDatabase : RoomDatabase() { } } private val MIGRATION_14_15 = object : Migration(14, 15) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ CREATE TABLE IF NOT EXISTS favorite_sync_records ( entityKey TEXT PRIMARY KEY NOT NULL, entityType TEXT NOT NULL, entityId TEXT NOT NULL, revision TEXT, isDirty INTEGER NOT NULL, isDeleted INTEGER NOT NULL ) """.trimIndent() ) } } fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( Loading @@ -260,6 +278,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, ).build() INSTANCE = instance instance Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/FavoriteSyncKeys.kt 0 → 100644 +33 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data.room object FavoriteSyncKeys { const val PLACE = "PLACE" const val LIST = "LIST" const val LIST_ITEM = "LIST_ITEM" fun place(id: String): String = "$PLACE:$id" fun list(id: String): String = "$LIST:$id" fun listItem(itemId: String, itemType: ItemType): String = "$LIST_ITEM:${itemType.name}:$itemId" fun listItemEntityId(itemId: String, itemType: ItemType): String = "${itemType.name}:$itemId" } cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/FavoriteSyncRecord.kt 0 → 100644 +32 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data.room import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "favorite_sync_records") data class FavoriteSyncRecord( @PrimaryKey val entityKey: String, val entityType: String, val entityId: String, val revision: String?, val isDirty: Boolean, val isDeleted: Boolean ) cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/FavoriteSyncRecordDao.kt 0 → 100644 +42 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data.room import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @Dao interface FavoriteSyncRecordDao { @Query("SELECT * FROM favorite_sync_records") suspend fun getAllRecords(): List<FavoriteSyncRecord> @Query("SELECT * FROM favorite_sync_records WHERE entityKey = :entityKey") suspend fun getRecord(entityKey: String): FavoriteSyncRecord? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRecord(record: FavoriteSyncRecord) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRecords(records: List<FavoriteSyncRecord>) @Query("DELETE FROM favorite_sync_records WHERE isDeleted = 1") suspend fun deleteTombstoneRecords() } cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedListRepository.kt +71 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ class SavedListRepository @Inject constructor( private val listDao = database.savedListDao() private val listItemDao = database.listItemDao() private val placeDao = database.savedPlaceDao() private val favoriteSyncRecordDao = database.favoriteSyncRecordDao() /** * Creates a new list. Loading @@ -64,6 +65,7 @@ class SavedListRepository @Inject constructor( ) listDao.insertList(list) markListDirty(id) val position = listItemDao.getItemsInList(parentId).size listItemDao.insertItem( Loading @@ -75,6 +77,7 @@ class SavedListRepository @Inject constructor( addedAt = System.currentTimeMillis() ) ) markListItemDirty(id, ItemType.LIST) Result.success(id) } catch (e: Exception) { Result.failure(e) Loading @@ -90,6 +93,12 @@ class SavedListRepository @Inject constructor( IllegalArgumentException("List not found") ) markDeleted(FavoriteSyncKeys.list(listId), FavoriteSyncKeys.LIST, listId) markDeleted( FavoriteSyncKeys.listItem(listId, ItemType.LIST), FavoriteSyncKeys.LIST_ITEM, FavoriteSyncKeys.listItemEntityId(listId, ItemType.LIST) ) listItemDao.orphanItem(listId, ItemType.LIST) listDao.deleteList(list) Result.success(Unit) Loading Loading @@ -118,6 +127,7 @@ class SavedListRepository @Inject constructor( ) listDao.updateList(updatedList) markListDirty(listId) Result.success(Unit) } catch (e: Exception) { Result.failure(e) Loading Loading @@ -208,6 +218,7 @@ class SavedListRepository @Inject constructor( ) listItemDao.insertItem(listItem) markListItemDirty(itemId, itemType) Result.success(Unit) } catch (e: Exception) { Result.failure(e) Loading @@ -222,6 +233,22 @@ class SavedListRepository @Inject constructor( ): Result<Unit> = withContext(Dispatchers.IO) { try { listItemDao.reorderItems(listId, items) items.forEach { markListItemDirty(it.itemId, it.itemType) } Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } suspend fun moveItem( itemId: String, itemType: ItemType, newListId: String, newPosition: Int ): Result<Unit> = withContext(Dispatchers.IO) { try { listItemDao.moveItem(itemId, newListId, newPosition) markListItemDirty(itemId, itemType) Result.success(Unit) } catch (e: Exception) { Result.failure(e) Loading Loading @@ -249,6 +276,50 @@ class SavedListRepository @Inject constructor( items.map { it.itemId }.toSet() } private suspend fun markListDirty(listId: String) { val key = FavoriteSyncKeys.list(listId) val existing = favoriteSyncRecordDao.getRecord(key) favoriteSyncRecordDao.upsertRecord( FavoriteSyncRecord( entityKey = key, entityType = FavoriteSyncKeys.LIST, entityId = listId, revision = existing?.revision, isDirty = true, isDeleted = false ) ) } private suspend fun markListItemDirty(itemId: String, itemType: ItemType) { val key = FavoriteSyncKeys.listItem(itemId, itemType) val existing = favoriteSyncRecordDao.getRecord(key) favoriteSyncRecordDao.upsertRecord( FavoriteSyncRecord( entityKey = key, entityType = FavoriteSyncKeys.LIST_ITEM, entityId = FavoriteSyncKeys.listItemEntityId(itemId, itemType), revision = existing?.revision, isDirty = true, isDeleted = false ) ) } private suspend fun markDeleted(entityKey: String, entityType: String, entityId: String) { val existing = favoriteSyncRecordDao.getRecord(entityKey) favoriteSyncRecordDao.upsertRecord( FavoriteSyncRecord( entityKey = entityKey, entityType = entityType, entityId = entityId, revision = existing?.revision, isDirty = true, isDeleted = true ) ) } /** * Gets the hierarchical content of a list for UI display. * Returns a flow of list of ListContent items (either PlaceContent or ListContentItem). Loading Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/AppDatabase.kt +21 −2 Original line number Diff line number Diff line Loading @@ -28,8 +28,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase import earth.maps.cardinal.data.DownloadStatusConverter @Database( entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class], version = 14, entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class, FavoriteSyncRecord::class], version = 15, exportSchema = false ) @TypeConverters(TileTypeConverter::class, DownloadStatusConverter::class, ItemTypeConverter::class) Loading @@ -41,6 +41,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun savedPlaceDao(): SavedPlaceDao abstract fun listItemDao(): ListItemDao abstract fun recentSearchDao(): RecentSearchDao abstract fun favoriteSyncRecordDao(): FavoriteSyncRecordDao companion object { @Volatile Loading Loading @@ -243,6 +244,23 @@ abstract class AppDatabase : RoomDatabase() { } } private val MIGRATION_14_15 = object : Migration(14, 15) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ CREATE TABLE IF NOT EXISTS favorite_sync_records ( entityKey TEXT PRIMARY KEY NOT NULL, entityType TEXT NOT NULL, entityId TEXT NOT NULL, revision TEXT, isDirty INTEGER NOT NULL, isDeleted INTEGER NOT NULL ) """.trimIndent() ) } } fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( Loading @@ -260,6 +278,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, ).build() INSTANCE = instance instance Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/FavoriteSyncKeys.kt 0 → 100644 +33 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data.room object FavoriteSyncKeys { const val PLACE = "PLACE" const val LIST = "LIST" const val LIST_ITEM = "LIST_ITEM" fun place(id: String): String = "$PLACE:$id" fun list(id: String): String = "$LIST:$id" fun listItem(itemId: String, itemType: ItemType): String = "$LIST_ITEM:${itemType.name}:$itemId" fun listItemEntityId(itemId: String, itemType: ItemType): String = "${itemType.name}:$itemId" }
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/FavoriteSyncRecord.kt 0 → 100644 +32 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data.room import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "favorite_sync_records") data class FavoriteSyncRecord( @PrimaryKey val entityKey: String, val entityType: String, val entityId: String, val revision: String?, val isDirty: Boolean, val isDeleted: Boolean )
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/FavoriteSyncRecordDao.kt 0 → 100644 +42 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data.room import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @Dao interface FavoriteSyncRecordDao { @Query("SELECT * FROM favorite_sync_records") suspend fun getAllRecords(): List<FavoriteSyncRecord> @Query("SELECT * FROM favorite_sync_records WHERE entityKey = :entityKey") suspend fun getRecord(entityKey: String): FavoriteSyncRecord? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRecord(record: FavoriteSyncRecord) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertRecords(records: List<FavoriteSyncRecord>) @Query("DELETE FROM favorite_sync_records WHERE isDeleted = 1") suspend fun deleteTombstoneRecords() }
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedListRepository.kt +71 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,7 @@ class SavedListRepository @Inject constructor( private val listDao = database.savedListDao() private val listItemDao = database.listItemDao() private val placeDao = database.savedPlaceDao() private val favoriteSyncRecordDao = database.favoriteSyncRecordDao() /** * Creates a new list. Loading @@ -64,6 +65,7 @@ class SavedListRepository @Inject constructor( ) listDao.insertList(list) markListDirty(id) val position = listItemDao.getItemsInList(parentId).size listItemDao.insertItem( Loading @@ -75,6 +77,7 @@ class SavedListRepository @Inject constructor( addedAt = System.currentTimeMillis() ) ) markListItemDirty(id, ItemType.LIST) Result.success(id) } catch (e: Exception) { Result.failure(e) Loading @@ -90,6 +93,12 @@ class SavedListRepository @Inject constructor( IllegalArgumentException("List not found") ) markDeleted(FavoriteSyncKeys.list(listId), FavoriteSyncKeys.LIST, listId) markDeleted( FavoriteSyncKeys.listItem(listId, ItemType.LIST), FavoriteSyncKeys.LIST_ITEM, FavoriteSyncKeys.listItemEntityId(listId, ItemType.LIST) ) listItemDao.orphanItem(listId, ItemType.LIST) listDao.deleteList(list) Result.success(Unit) Loading Loading @@ -118,6 +127,7 @@ class SavedListRepository @Inject constructor( ) listDao.updateList(updatedList) markListDirty(listId) Result.success(Unit) } catch (e: Exception) { Result.failure(e) Loading Loading @@ -208,6 +218,7 @@ class SavedListRepository @Inject constructor( ) listItemDao.insertItem(listItem) markListItemDirty(itemId, itemType) Result.success(Unit) } catch (e: Exception) { Result.failure(e) Loading @@ -222,6 +233,22 @@ class SavedListRepository @Inject constructor( ): Result<Unit> = withContext(Dispatchers.IO) { try { listItemDao.reorderItems(listId, items) items.forEach { markListItemDirty(it.itemId, it.itemType) } Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } suspend fun moveItem( itemId: String, itemType: ItemType, newListId: String, newPosition: Int ): Result<Unit> = withContext(Dispatchers.IO) { try { listItemDao.moveItem(itemId, newListId, newPosition) markListItemDirty(itemId, itemType) Result.success(Unit) } catch (e: Exception) { Result.failure(e) Loading Loading @@ -249,6 +276,50 @@ class SavedListRepository @Inject constructor( items.map { it.itemId }.toSet() } private suspend fun markListDirty(listId: String) { val key = FavoriteSyncKeys.list(listId) val existing = favoriteSyncRecordDao.getRecord(key) favoriteSyncRecordDao.upsertRecord( FavoriteSyncRecord( entityKey = key, entityType = FavoriteSyncKeys.LIST, entityId = listId, revision = existing?.revision, isDirty = true, isDeleted = false ) ) } private suspend fun markListItemDirty(itemId: String, itemType: ItemType) { val key = FavoriteSyncKeys.listItem(itemId, itemType) val existing = favoriteSyncRecordDao.getRecord(key) favoriteSyncRecordDao.upsertRecord( FavoriteSyncRecord( entityKey = key, entityType = FavoriteSyncKeys.LIST_ITEM, entityId = FavoriteSyncKeys.listItemEntityId(itemId, itemType), revision = existing?.revision, isDirty = true, isDeleted = false ) ) } private suspend fun markDeleted(entityKey: String, entityType: String, entityId: String) { val existing = favoriteSyncRecordDao.getRecord(entityKey) favoriteSyncRecordDao.upsertRecord( FavoriteSyncRecord( entityKey = entityKey, entityType = entityType, entityId = entityId, revision = existing?.revision, isDirty = true, isDeleted = true ) ) } /** * Gets the hierarchical content of a list for UI display. * Returns a flow of list of ListContent items (either PlaceContent or ListContentItem). Loading