From 9045a122cc77897f2f47c2a747ea9bfa68442f22 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:58:50 +0300 Subject: [PATCH 1/7] Propagate tag deletions to Drive sync Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../20.json | 347 ++++++++++++++++++ .../anod/appwatcher/backup/DbBackupManager.kt | 1 + .../appwatcher/backup/gdrive/GDriveSync.kt | 97 ++--- .../appwatcher/backup/gdrive/GDriveUpload.kt | 11 +- .../anod/appwatcher/database/AppsDatabase.kt | 19 +- .../appwatcher/database/DeletedTagsTable.kt | 34 ++ .../com/anod/appwatcher/database/TagsTable.kt | 35 +- .../database/entities/DeletedTag.kt | 13 + .../anod/appwatcher/tags/EditTagViewModel.kt | 2 +- .../backup/gdrive/GDriveTagSyncTest.kt | 143 ++++++++ 10 files changed, 637 insertions(+), 65 deletions(-) create mode 100644 app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json create mode 100644 app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt create mode 100644 app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt create mode 100644 app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt diff --git a/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json b/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json new file mode 100644 index 00000000..91facb6a --- /dev/null +++ b/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json @@ -0,0 +1,347 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "9f75d57abc9c9d0af787861f1ce3e356", + "entities": [ + { + "tableName": "app_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `package` TEXT NOT NULL, `ver_num` INTEGER NOT NULL, `ver_name` TEXT NOT NULL, `title` TEXT NOT NULL, `creator` TEXT NOT NULL, `iconUrl` TEXT NOT NULL, `status` INTEGER NOT NULL, `upload_date` TEXT NOT NULL, `details_url` TEXT, `update_date` INTEGER NOT NULL, `app_type` TEXT NOT NULL, `sync_version` INTEGER NOT NULL, `price_text` TEXT NOT NULL, `price_currency` TEXT NOT NULL, `price_micros` INTEGER, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionNumber", + "columnName": "ver_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionName", + "columnName": "ver_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creator", + "columnName": "creator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "iconUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detailsUrl", + "columnName": "details_url", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadTime", + "columnName": "update_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appType", + "columnName": "app_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncTime", + "columnName": "sync_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "price.text", + "columnName": "price_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price.cur", + "columnName": "price_currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price.micros", + "columnName": "price_micros", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "changelog", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `code` INTEGER NOT NULL, `name` TEXT NOT NULL, `details` TEXT NOT NULL, `upload_date` TEXT NOT NULL, `no_new_details` INTEGER NOT NULL, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "code", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionName", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "details", + "columnName": "details", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "noNewDetails", + "columnName": "no_new_details", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [ + { + "name": "index_changelog_app_id_code", + "unique": true, + "columnNames": [ + "app_id", + "code" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_changelog_app_id_code` ON `${TABLE_NAME}` (`app_id`, `code`)" + } + ] + }, + { + "tableName": "app_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `tags_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appId", + "columnName": "app_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tags_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [ + { + "name": "index_app_tags_app_id_tags_id", + "unique": true, + "columnNames": [ + "app_id", + "tags_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_tags_app_id_tags_id` ON `${TABLE_NAME}` (`app_id`, `tags_id`)" + } + ] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "schedules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `start` INTEGER NOT NULL, `finish` INTEGER NOT NULL, `reason` INTEGER NOT NULL, `result` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `found` INTEGER NOT NULL, `unavailable` INTEGER NOT NULL, `notified` INTEGER NOT NULL, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "finish", + "columnName": "finish", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checked", + "columnName": "checked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "found", + "columnName": "found", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unavailable", + "columnName": "unavailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notified", + "columnName": "notified", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "deleted_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f75d57abc9c9d0af787861f1ce3e356')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt b/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt index 5868791a..76f7c152 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt @@ -97,6 +97,7 @@ class DbBackupManager(private val context: info.anodsplace.context.ApplicationCo db.apps().delete() db.tags().delete() db.appTags().delete() + db.deletedTags().deleteAll() result.apps.forEach { app -> db.apps().insert( diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt index 39308edc..561daa92 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt @@ -13,6 +13,7 @@ import com.anod.appwatcher.database.entities.Tag import com.google.android.gms.auth.UserRecoverableAuthException import info.anodsplace.applog.AppLog import java.io.BufferedReader +import java.io.Reader import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -60,72 +61,80 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a } } + val deletedTagNames = db.deletedTags().loadNames() val bytes = file.write(DbJsonWriter(), db) AppLog.i("Uploaded ${Formatter.formatShortFileSize(context.actual, bytes)}", "GDriveSync") - AppLog.d("Clean locally deleted apps") - // Clean deleted + AppLog.d("Clean locally deleted apps and tags") val numRows = db.apps().cleanDeleted() val numTags = db.appTags().clean() AppTagsTable.Queries.clean(db) - AppLog.i("Cleaned $numRows locally deleted apps, $numTags tags", "GDriveSync") + val numDeletedTags = if (bytes > 0 && deletedTagNames.isNotEmpty()) { + db.deletedTags().delete(deletedTagNames) + } else { + 0 + } + AppLog.i("Cleaned $numRows locally deleted apps, $numTags tags, $numDeletedTags deleted tag records", "GDriveSync") } @Throws(Exception::class) private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) = withContext(Dispatchers.IO) { val reader = file.read() ?: throw IllegalStateException("Cannot read file") + insertRemoteItems(reader, db) + } - // Add missing remote entries - val driveBufferedReader = BufferedReader(reader) - val jsonReader = DbJsonReader() - - val currentIds = db.apps().loadPackages(true).associate { it.packageName to it.rowId } - val currentTags = db.tags().load().associateBy { it.name }.toMutableMap() - - val tagList = mutableListOf() - val tagApps = mutableMapOf>() - - jsonReader.read(driveBufferedReader, object : DbJsonReader.OnReadListener { - override suspend fun onAppRead(app: App, tags: List) { - AppLog.d("[GDrive] Read app: " + app.packageName) - if (!currentIds.containsKey(app.packageName)) { - AppListTable.Queries.insert(app, db) - } - tags.forEach { - if (tagApps[it] == null) { - tagApps[it] = mutableListOf() + companion object { + internal suspend fun insertRemoteItems(reader: Reader, db: AppsDatabase) = withContext(Dispatchers.IO) { + // Add missing remote entries + val driveBufferedReader = BufferedReader(reader) + val jsonReader = DbJsonReader() + + val currentIds = db.apps().loadPackages(true).associate { it.packageName to it.rowId } + val currentTags = db.tags().load().associateBy { it.name }.toMutableMap() + val deletedTagNames = db.deletedTags().loadNames().toSet() + + val tagList = mutableListOf() + val tagApps = mutableMapOf>() + + jsonReader.read(driveBufferedReader, object : DbJsonReader.OnReadListener { + override suspend fun onAppRead(app: App, tags: List) { + AppLog.d("[GDrive] Read app: " + app.packageName) + if (!currentIds.containsKey(app.packageName)) { + AppListTable.Queries.insert(app, db) + } + tags.forEach { + if (tagApps[it] == null) { + tagApps[it] = mutableListOf() + } + tagApps[it]!!.add(app.appId) } - tagApps[it]!!.add(app.appId) } - } - override suspend fun onTagRead(tag: Tag) { - tagList.add(tag) - } + override suspend fun onTagRead(tag: Tag) { + tagList.add(tag) + } - override suspend fun onFinish(appsRead: Int, tagsRead: Int) { - reader.close() - AppLog.i("Read from remote $appsRead apps, $tagsRead tags", "GDriveSync") - // Add missing tags - tagList.forEach { tag -> - if (!currentTags.containsKey(tag.name)) { - val rowId = TagsTable.Queries.insert(Tag(tag.name, tag.color), db).toInt() - if (rowId > 0) { - currentTags[tag.name] = Tag(rowId, tag.name, tag.color) + override suspend fun onFinish(appsRead: Int, tagsRead: Int) { + AppLog.i("Read from remote $appsRead apps, $tagsRead tags", "GDriveSync") + // Add missing tags + tagList.forEach { tag -> + if (tag.name !in deletedTagNames && !currentTags.containsKey(tag.name)) { + val rowId = TagsTable.Queries.insert(Tag(tag.name, tag.color), db).toInt() + if (rowId > 0) { + currentTags[tag.name] = Tag(rowId, tag.name, tag.color) + } } } - } - tagApps.forEach { (tagName, apps) -> - currentTags[tagName]?.let { tag -> - AppTagsTable.Queries.insert(tag, apps, db) + tagApps.forEach { (tagName, apps) -> + currentTags[tagName]?.takeIf { tagName !in deletedTagNames }?.let { tag -> + AppTagsTable.Queries.insert(tag, apps, db) + } } } - } - }) - } + }) + } - companion object { /** * Lock used when maintaining queue of requested updates. */ diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt index 0dcddde7..5c3b4e6f 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt @@ -40,15 +40,20 @@ class GDriveUpload(private val googleAccount: Account, private val context: Appl file.create() } + val deletedTagNames = db.deletedTags().loadNames() val bytes = file.write(DbJsonWriter(), db) AppLog.i("Uploaded ${Formatter.formatShortFileSize(context.actual, bytes)}", "GDriveUpload") - AppLog.d("[GDrive] Clean locally deleted apps ") - // Clean deleted + AppLog.d("[GDrive] Clean locally deleted apps and tags") val numRows = db.apps().cleanDeleted() val numTags = db.appTags().clean() AppTagsTable.Queries.clean(db) - AppLog.i("Cleaned $numRows locally deleted apps, $numTags tags", "GDriveUpload") + val numDeletedTags = if (bytes > 0 && deletedTagNames.isNotEmpty()) { + db.deletedTags().delete(deletedTagNames) + } else { + 0 + } + AppLog.i("Cleaned $numRows locally deleted apps, $numTags tags, $numDeletedTags deleted tag records", "GDriveUpload") } companion object { diff --git a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt index 41a424f2..836d8296 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt @@ -8,6 +8,7 @@ import com.anod.appwatcher.BuildConfig import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppChange import com.anod.appwatcher.database.entities.AppTag +import com.anod.appwatcher.database.entities.DeletedTag import com.anod.appwatcher.database.entities.Schedule import com.anod.appwatcher.database.entities.Tag import info.anodsplace.applog.AppLog @@ -17,7 +18,7 @@ import info.anodsplace.applog.AppLog * @date 21/05/2018 */ @Database( - entities = [(App::class), (AppChange::class), (AppTag::class), (Tag::class), (Schedule::class)], + entities = [(App::class), (AppChange::class), (AppTag::class), (Tag::class), (Schedule::class), (DeletedTag::class)], version = AppsDatabase.VERSION, exportSchema = true) abstract class AppsDatabase : RoomDatabase() { @@ -27,9 +28,10 @@ abstract class AppsDatabase : RoomDatabase() { abstract fun tags(): TagsTable abstract fun appTags(): AppTagsTable abstract fun schedules(): SchedulesTable + abstract fun deletedTags(): DeletedTagsTable companion object { - const val VERSION = 19 + const val VERSION = 20 val dbName = if (BuildConfig.DEBUG) "app_watcher.db" else "app_watcher" private val MIGRATION_17_18 = object : Migration(17, 18) { @@ -193,6 +195,16 @@ abstract class AppsDatabase : RoomDatabase() { } } + private val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `${DeletedTagsTable.TABLE}` (" + + "`${DeletedTagsTable.Columns.NAME}` TEXT NOT NULL, " + + "PRIMARY KEY(`${DeletedTagsTable.Columns.NAME}`))" + ) + } + } + val migrations: Array = arrayOf( MIGRATION_9_11, MIGRATION_11_12, @@ -202,7 +214,8 @@ abstract class AppsDatabase : RoomDatabase() { MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, - MIGRATION_18_19 + MIGRATION_18_19, + MIGRATION_19_20 ) } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt new file mode 100644 index 00000000..c565c089 --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt @@ -0,0 +1,34 @@ +package com.anod.appwatcher.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.anod.appwatcher.database.entities.DeletedTag + +@Dao +interface DeletedTagsTable { + + @Query("SELECT ${Columns.NAME} FROM $TABLE") + suspend fun loadNames(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(tag: DeletedTag) + + @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} = :name") + suspend fun delete(name: String): Int + + @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} IN (:names)") + suspend fun delete(names: List): Int + + @Query("DELETE FROM $TABLE") + suspend fun deleteAll(): Int + + object Columns { + const val NAME = "name" + } + + companion object { + const val TABLE = "deleted_tags" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt index 5c59b9b0..acb179c8 100644 --- a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt @@ -1,17 +1,14 @@ package com.anod.appwatcher.database -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase import android.provider.BaseColumns import androidx.room.Dao import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import androidx.room.withTransaction +import com.anod.appwatcher.database.entities.DeletedTag import com.anod.appwatcher.database.entities.Tag -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext /** * @author alex @@ -36,6 +33,9 @@ interface TagsTable { @Query("SELECT ${BaseColumns._ID} FROM $TABLE") suspend fun loadIds(): List + @Query("SELECT COUNT(*) FROM $TABLE WHERE ${Columns.NAME} = :name") + suspend fun countByName(name: String): Int + @Query("DELETE FROM $TABLE WHERE ${BaseColumns._ID} = :tagId") suspend fun delete(tagId: Int) @@ -52,21 +52,28 @@ interface TagsTable { suspend fun delete(tag: Tag, db: AppsDatabase) = db.withTransaction { db.tags().delete(tag.id) db.appTags().delete(tag.id) + if (db.tags().countByName(tag.name) == 0) { + db.deletedTags().insert(DeletedTag(tag.name)) + } } - suspend fun insert(tag: Tag, db: AppsDatabase): Long { - // Skip id to apply autoincrement - val values = ContentValues().apply { - put(Columns.NAME, tag.name) - put(Columns.COLOR, tag.color) + suspend fun insert(tag: Tag, db: AppsDatabase): Long = db.withTransaction { + val rowId = db.tags().insert(tag.name, tag.color) + if (rowId > 0) { + db.deletedTags().delete(tag.name) } - var rowId = 0L - withContext(Dispatchers.IO) { - db.runInTransaction { - rowId = db.openHelper.writableDatabase.insert(TABLE, SQLiteDatabase.CONFLICT_REPLACE, values) + rowId + } + + suspend fun update(tag: Tag, db: AppsDatabase) = db.withTransaction { + val previousTag = db.tags().loadById(tag.id) + db.tags().update(tag) + db.deletedTags().delete(tag.name) + if (previousTag != null && previousTag.name != tag.name) { + if (db.tags().countByName(previousTag.name) == 0) { + db.deletedTags().insert(DeletedTag(previousTag.name)) } } - return rowId } } diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt new file mode 100644 index 00000000..bf32a9df --- /dev/null +++ b/app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt @@ -0,0 +1,13 @@ +package com.anod.appwatcher.database.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.anod.appwatcher.database.DeletedTagsTable + +@Entity(tableName = DeletedTagsTable.TABLE) +data class DeletedTag( + @PrimaryKey + @ColumnInfo(name = DeletedTagsTable.Columns.NAME) + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt b/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt index 4d6b2d39..f7302ff4 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt @@ -68,7 +68,7 @@ class EditTagViewModel(tag: Tag) : BaseFlowViewModel 0) { - database.tags().update(tag) + TagsTable.Queries.update(tag, database) } else { val tagId = TagsTable.Queries.insert(tag, database).toInt() viewState = viewState.copy(tag = tag.copy(id = tagId)) diff --git a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt new file mode 100644 index 00000000..ed031c85 --- /dev/null +++ b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt @@ -0,0 +1,143 @@ +package com.anod.appwatcher.backup.gdrive + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.anod.appwatcher.backup.DbJsonReader +import com.anod.appwatcher.backup.DbJsonWriter +import com.anod.appwatcher.database.AppListTable +import com.anod.appwatcher.database.AppsDatabase +import com.anod.appwatcher.database.TagsTable +import com.anod.appwatcher.database.entities.App +import com.anod.appwatcher.database.entities.DeletedTag +import com.anod.appwatcher.database.entities.Price +import com.anod.appwatcher.database.entities.Tag +import java.io.StringReader +import java.io.StringWriter +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +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.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class GDriveTagSyncTest { + + private lateinit var db: AppsDatabase + + @Before + fun createDb() { + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppsDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun deletedTagIsNotRestoredAndIsExcludedFromNextUpload() = runBlocking { + val tag = insertTag("Deleted") + insertApp("tagged") + db.appTags().insert("tagged", tag.id) + val staleRemote = writeDatabase() + + TagsTable.Queries.delete(tag, db) + GDriveSync.insertRemoteItems(StringReader(staleRemote), db) + + assertTrue(db.tags().load().isEmpty()) + assertTrue(db.appTags().load().isEmpty()) + assertEquals(listOf(tag.name), db.deletedTags().loadNames()) + assertTrue(DbJsonReader().read(StringReader(writeDatabase())).tags.isEmpty()) + } + + @Test + fun recreatedTagIsNotDuplicatedByStaleRemoteData() = runBlocking { + val deletedTag = insertTag("Recreated") + val staleRemote = writeDatabase() + TagsTable.Queries.delete(deletedTag, db) + val recreatedTag = insertTag("Recreated") + + GDriveSync.insertRemoteItems(StringReader(staleRemote), db) + GDriveSync.insertRemoteItems(StringReader(staleRemote), db) + + assertEquals(listOf(recreatedTag), db.tags().load()) + assertEquals(listOf(recreatedTag), DbJsonReader().read(StringReader(writeDatabase())).tags) + } + + @Test + fun existingTagCreateAndUpdateMergeBehaviorIsPreserved() = runBlocking { + val existingTag = insertTag("Existing") + val staleRemote = writeDatabase() + val updatedTag = existingTag.copy(color = 0xFF00FF00.toInt()) + db.tags().update(updatedTag) + val createdTag = insertTag("Created") + + GDriveSync.insertRemoteItems(StringReader(staleRemote), db) + + assertEquals(setOf(createdTag, updatedTag), db.tags().load().toSet()) + assertEquals(setOf(createdTag, updatedTag), DbJsonReader().read(StringReader(writeDatabase())).tags.toSet()) + } + + @Test + fun uploadAcknowledgesOnlyTombstonesPresentBeforeWriting() = runBlocking { + db.deletedTags().insert(DeletedTag("Uploaded")) + val uploadedTagNames = db.deletedTags().loadNames() + db.deletedTags().insert(DeletedTag("Deleted during upload")) + + db.deletedTags().delete(uploadedTagNames) + + assertEquals(listOf("Deleted during upload"), db.deletedTags().loadNames()) + } + + @Test + fun renamingTagTombstonesOldNameAndClearsNewNameTombstone() = runBlocking { + val tag = insertTag("Old") + db.deletedTags().insert(DeletedTag("New")) + + TagsTable.Queries.update(tag.copy(name = "New"), db) + + assertEquals(listOf("New"), db.tags().load().map { it.name }) + assertEquals(listOf("Old"), db.deletedTags().loadNames()) + } + + private suspend fun insertTag(name: String): Tag { + val id = TagsTable.Queries.insert(Tag(name), db).toInt() + return db.tags().loadById(id)!! + } + + private suspend fun insertApp(appId: String) { + AppListTable.Queries.insert( + App( + rowId = 0, + appId = appId, + packageName = "$appId.package", + versionNumber = 1, + versionName = "1.0", + title = appId, + creator = "creator", + iconUrl = "", + status = App.STATUS_NORMAL, + uploadDate = "", + price = Price("", "", 0), + detailsUrl = null, + uploadTime = 0, + appType = "", + syncTime = 0 + ), + db + ) + } + + private suspend fun writeDatabase(): String { + val writer = StringWriter() + DbJsonWriter().write(writer, db) + return writer.toString() + } +} \ No newline at end of file From 0041db11abf693fc2026236d9c9f9a7acea129cc Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:36:26 +0300 Subject: [PATCH 2/7] Remove redundant sync context switch Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt index 561daa92..7eeff84c 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt @@ -78,7 +78,7 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a } @Throws(Exception::class) - private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) = withContext(Dispatchers.IO) { + private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) { val reader = file.read() ?: throw IllegalStateException("Cannot read file") insertRemoteItems(reader, db) } From 232e7adaddb81354a903be166d468db86382c7a8 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:37:39 +0300 Subject: [PATCH 3/7] Track deleted tags by status Replace the separate deleted-tag tombstone table with a status on Tag, matching the existing app deletion model while keeping deleted tags out of normal queries and Drive snapshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../20.json | 31 +++------ .../com/anod/appwatcher/AppWatcherActivity.kt | 6 +- .../anod/appwatcher/backup/DbBackupManager.kt | 1 - .../anod/appwatcher/backup/TagJsonObject.kt | 2 +- .../appwatcher/backup/gdrive/GDriveSync.kt | 10 +-- .../appwatcher/backup/gdrive/GDriveUpload.kt | 6 +- .../anod/appwatcher/database/AppsDatabase.kt | 10 +-- .../appwatcher/database/DeletedTagsTable.kt | 34 ---------- .../com/anod/appwatcher/database/TagsTable.kt | 66 +++++++++++++------ .../database/entities/DeletedTag.kt | 13 ---- .../anod/appwatcher/database/entities/Tag.kt | 15 +++-- .../com/anod/appwatcher/tags/AppsTagDialog.kt | 2 +- .../backup/gdrive/GDriveTagSyncTest.kt | 19 +++--- .../compose/Material3TextFieldSurfacesTest.kt | 4 +- 14 files changed, 95 insertions(+), 124 deletions(-) delete mode 100644 app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt delete mode 100644 app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt diff --git a/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json b/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json index 91facb6a..73a3e793 100644 --- a/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json +++ b/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 20, - "identityHash": "9f75d57abc9c9d0af787861f1ce3e356", + "identityHash": "5f512b5daa6022e167f0a9d6c437b984", "entities": [ { "tableName": "app_list", @@ -226,7 +226,7 @@ }, { "tableName": "tags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, PRIMARY KEY(`_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `status` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`_id`))", "fields": [ { "fieldPath": "id", @@ -245,6 +245,13 @@ "columnName": "color", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -319,29 +326,11 @@ "_id" ] } - }, - { - "tableName": "deleted_tags", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))", - "fields": [ - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "name" - ] - } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f75d57abc9c9d0af787861f1ce3e356')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f512b5daa6022e167f0a9d6c437b984')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt b/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt index 0b4c4321..db707664 100644 --- a/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt +++ b/app/src/main/java/com/anod/appwatcher/AppWatcherActivity.kt @@ -162,7 +162,8 @@ class AppWatcherActivity : BaseComposeActivity(), KoinComponent { tag = Tag( id = extraTagId, name = "", - color = extraTagColor + color = extraTagColor, + status = Tag.STATUS_NORMAL ) )) } @@ -177,7 +178,8 @@ class AppWatcherActivity : BaseComposeActivity(), KoinComponent { tag = Tag( id = extraTagId, name = "", - color = extras.getInt(EXTRA_LIST_TAG_COLOR) + color = extras.getInt(EXTRA_LIST_TAG_COLOR), + status = Tag.STATUS_NORMAL ) ) } else if (extras.containsKey(EXTRA_GDRIVE_SIGNIN)) { diff --git a/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt b/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt index 76f7c152..5868791a 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt @@ -97,7 +97,6 @@ class DbBackupManager(private val context: info.anodsplace.context.ApplicationCo db.apps().delete() db.tags().delete() db.appTags().delete() - db.deletedTags().deleteAll() result.apps.forEach { app -> db.apps().insert( diff --git a/app/src/main/java/com/anod/appwatcher/backup/TagJsonObject.kt b/app/src/main/java/com/anod/appwatcher/backup/TagJsonObject.kt index 0094fe0a..6c3388d0 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/TagJsonObject.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/TagJsonObject.kt @@ -41,7 +41,7 @@ class TagJsonObject(val tag: Tag?) { reader.endObject() if (id > 0) { - return Tag(id, name, color) + return Tag(id, name, color, Tag.STATUS_NORMAL) } return null } diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt index 7eeff84c..2c60f27c 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt @@ -61,7 +61,7 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a } } - val deletedTagNames = db.deletedTags().loadNames() + val deletedTagIds = db.tags().loadDeletedIds() val bytes = file.write(DbJsonWriter(), db) AppLog.i("Uploaded ${Formatter.formatShortFileSize(context.actual, bytes)}", "GDriveSync") @@ -69,8 +69,8 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a val numRows = db.apps().cleanDeleted() val numTags = db.appTags().clean() AppTagsTable.Queries.clean(db) - val numDeletedTags = if (bytes > 0 && deletedTagNames.isNotEmpty()) { - db.deletedTags().delete(deletedTagNames) + val numDeletedTags = if (bytes > 0 && deletedTagIds.isNotEmpty()) { + db.tags().deleteDeleted(deletedTagIds) } else { 0 } @@ -91,7 +91,7 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a val currentIds = db.apps().loadPackages(true).associate { it.packageName to it.rowId } val currentTags = db.tags().load().associateBy { it.name }.toMutableMap() - val deletedTagNames = db.deletedTags().loadNames().toSet() + val deletedTagNames = db.tags().loadDeletedNames().toSet() val tagList = mutableListOf() val tagApps = mutableMapOf>() @@ -121,7 +121,7 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a if (tag.name !in deletedTagNames && !currentTags.containsKey(tag.name)) { val rowId = TagsTable.Queries.insert(Tag(tag.name, tag.color), db).toInt() if (rowId > 0) { - currentTags[tag.name] = Tag(rowId, tag.name, tag.color) + currentTags[tag.name] = Tag(rowId, tag.name, tag.color, Tag.STATUS_NORMAL) } } } diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt index 5c3b4e6f..d4ff7f96 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt @@ -40,7 +40,7 @@ class GDriveUpload(private val googleAccount: Account, private val context: Appl file.create() } - val deletedTagNames = db.deletedTags().loadNames() + val deletedTagIds = db.tags().loadDeletedIds() val bytes = file.write(DbJsonWriter(), db) AppLog.i("Uploaded ${Formatter.formatShortFileSize(context.actual, bytes)}", "GDriveUpload") @@ -48,8 +48,8 @@ class GDriveUpload(private val googleAccount: Account, private val context: Appl val numRows = db.apps().cleanDeleted() val numTags = db.appTags().clean() AppTagsTable.Queries.clean(db) - val numDeletedTags = if (bytes > 0 && deletedTagNames.isNotEmpty()) { - db.deletedTags().delete(deletedTagNames) + val numDeletedTags = if (bytes > 0 && deletedTagIds.isNotEmpty()) { + db.tags().deleteDeleted(deletedTagIds) } else { 0 } diff --git a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt index 836d8296..a37ba976 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt @@ -8,7 +8,6 @@ import com.anod.appwatcher.BuildConfig import com.anod.appwatcher.database.entities.App import com.anod.appwatcher.database.entities.AppChange import com.anod.appwatcher.database.entities.AppTag -import com.anod.appwatcher.database.entities.DeletedTag import com.anod.appwatcher.database.entities.Schedule import com.anod.appwatcher.database.entities.Tag import info.anodsplace.applog.AppLog @@ -18,7 +17,7 @@ import info.anodsplace.applog.AppLog * @date 21/05/2018 */ @Database( - entities = [(App::class), (AppChange::class), (AppTag::class), (Tag::class), (Schedule::class), (DeletedTag::class)], + entities = [(App::class), (AppChange::class), (AppTag::class), (Tag::class), (Schedule::class)], version = AppsDatabase.VERSION, exportSchema = true) abstract class AppsDatabase : RoomDatabase() { @@ -28,7 +27,6 @@ abstract class AppsDatabase : RoomDatabase() { abstract fun tags(): TagsTable abstract fun appTags(): AppTagsTable abstract fun schedules(): SchedulesTable - abstract fun deletedTags(): DeletedTagsTable companion object { const val VERSION = 20 @@ -197,11 +195,7 @@ abstract class AppsDatabase : RoomDatabase() { private val MIGRATION_19_20 = object : Migration(19, 20) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "CREATE TABLE IF NOT EXISTS `${DeletedTagsTable.TABLE}` (" + - "`${DeletedTagsTable.Columns.NAME}` TEXT NOT NULL, " + - "PRIMARY KEY(`${DeletedTagsTable.Columns.NAME}`))" - ) + db.execSQL("ALTER TABLE ${TagsTable.TABLE} ADD COLUMN ${TagsTable.Columns.STATUS} INTEGER NOT NULL DEFAULT ${Tag.STATUS_NORMAL}") } } diff --git a/app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt deleted file mode 100644 index c565c089..00000000 --- a/app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.anod.appwatcher.database - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.anod.appwatcher.database.entities.DeletedTag - -@Dao -interface DeletedTagsTable { - - @Query("SELECT ${Columns.NAME} FROM $TABLE") - suspend fun loadNames(): List - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(tag: DeletedTag) - - @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} = :name") - suspend fun delete(name: String): Int - - @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} IN (:names)") - suspend fun delete(names: List): Int - - @Query("DELETE FROM $TABLE") - suspend fun deleteAll(): Int - - object Columns { - const val NAME = "name" - } - - companion object { - const val TABLE = "deleted_tags" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt index acb179c8..6f9aaf61 100644 --- a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt @@ -6,7 +6,6 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import androidx.room.withTransaction -import com.anod.appwatcher.database.entities.DeletedTag import com.anod.appwatcher.database.entities.Tag import kotlinx.coroutines.flow.Flow @@ -18,62 +17,86 @@ import kotlinx.coroutines.flow.Flow @Dao interface TagsTable { - @Query("SELECT * FROM $TABLE ORDER BY ${Columns.NAME} COLLATE LOCALIZED ASC") + @Query("SELECT * FROM $TABLE WHERE ${Columns.STATUS} = ${Tag.STATUS_NORMAL} ORDER BY ${Columns.NAME} COLLATE LOCALIZED ASC") fun observe(): Flow> - @Query("SELECT * FROM $TABLE WHERE ${BaseColumns._ID} = :tagId") + @Query("SELECT * FROM $TABLE WHERE ${BaseColumns._ID} = :tagId AND ${Columns.STATUS} = ${Tag.STATUS_NORMAL}") fun observeTag(tagId: Int): Flow - @Query("SELECT * FROM $TABLE ORDER BY ${Columns.NAME} COLLATE LOCALIZED ASC") + @Query("SELECT * FROM $TABLE WHERE ${Columns.STATUS} = ${Tag.STATUS_NORMAL} ORDER BY ${Columns.NAME} COLLATE LOCALIZED ASC") suspend fun load(): List - @Query("SELECT * FROM $TABLE WHERE ${BaseColumns._ID} = :tagId") + @Query("SELECT * FROM $TABLE WHERE ${BaseColumns._ID} = :tagId AND ${Columns.STATUS} = ${Tag.STATUS_NORMAL}") suspend fun loadById(tagId: Int): Tag? - @Query("SELECT ${BaseColumns._ID} FROM $TABLE") + @Query("SELECT ${BaseColumns._ID} FROM $TABLE WHERE ${Columns.STATUS} = ${Tag.STATUS_NORMAL}") suspend fun loadIds(): List - @Query("SELECT COUNT(*) FROM $TABLE WHERE ${Columns.NAME} = :name") + @Query("SELECT COUNT(*) FROM $TABLE WHERE ${Columns.NAME} = :name AND ${Columns.STATUS} = ${Tag.STATUS_NORMAL}") suspend fun countByName(name: String): Int + @Query("SELECT * FROM $TABLE WHERE ${Columns.NAME} = :name AND ${Columns.STATUS} = ${Tag.STATUS_DELETED} LIMIT 1") + suspend fun loadDeletedByName(name: String): Tag? + + @Query("SELECT ${BaseColumns._ID} FROM $TABLE WHERE ${Columns.STATUS} = ${Tag.STATUS_DELETED}") + suspend fun loadDeletedIds(): List + + @Query("SELECT ${Columns.NAME} FROM $TABLE WHERE ${Columns.STATUS} = ${Tag.STATUS_DELETED}") + suspend fun loadDeletedNames(): List + @Query("DELETE FROM $TABLE WHERE ${BaseColumns._ID} = :tagId") suspend fun delete(tagId: Int) + @Query("DELETE FROM $TABLE WHERE ${BaseColumns._ID} IN (:tagIds) AND ${Columns.STATUS} = ${Tag.STATUS_DELETED}") + suspend fun deleteDeleted(tagIds: List): Int + + @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} = :name AND ${Columns.STATUS} = ${Tag.STATUS_DELETED}") + suspend fun deleteDeleted(name: String): Int + + @Query("UPDATE $TABLE SET ${Columns.STATUS} = :status WHERE ${BaseColumns._ID} = :tagId") + suspend fun updateStatus(tagId: Int, status: Int): Int + @Update(onConflict = OnConflictStrategy.REPLACE) suspend fun update(tag: Tag) @Query("DELETE FROM $TABLE") suspend fun delete() - @Query("INSERT INTO $TABLE (${Columns.NAME}, ${Columns.COLOR}) VALUES (:name, :color)") + @Query("INSERT INTO $TABLE (${Columns.NAME}, ${Columns.COLOR}, ${Columns.STATUS}) VALUES (:name, :color, ${Tag.STATUS_NORMAL})") suspend fun insert(name: String, color: Int): Long + @Query("INSERT INTO $TABLE (${Columns.NAME}, ${Columns.COLOR}, ${Columns.STATUS}) VALUES (:name, :color, ${Tag.STATUS_DELETED})") + suspend fun insertDeleted(name: String, color: Int): Long + object Queries { suspend fun delete(tag: Tag, db: AppsDatabase) = db.withTransaction { - db.tags().delete(tag.id) db.appTags().delete(tag.id) - if (db.tags().countByName(tag.name) == 0) { - db.deletedTags().insert(DeletedTag(tag.name)) + if (db.tags().countByName(tag.name) > 1) { + db.tags().delete(tag.id) + } else { + db.tags().updateStatus(tag.id, Tag.STATUS_DELETED) } } suspend fun insert(tag: Tag, db: AppsDatabase): Long = db.withTransaction { - val rowId = db.tags().insert(tag.name, tag.color) - if (rowId > 0) { - db.deletedTags().delete(tag.name) + val deletedTag = db.tags().loadDeletedByName(tag.name) + if (deletedTag != null) { + db.tags().update(deletedTag.copy(color = tag.color, status = Tag.STATUS_NORMAL)) + deletedTag.id.toLong() + } else { + db.tags().insert(tag.name, tag.color) } - rowId } suspend fun update(tag: Tag, db: AppsDatabase) = db.withTransaction { val previousTag = db.tags().loadById(tag.id) - db.tags().update(tag) - db.deletedTags().delete(tag.name) if (previousTag != null && previousTag.name != tag.name) { - if (db.tags().countByName(previousTag.name) == 0) { - db.deletedTags().insert(DeletedTag(previousTag.name)) + db.tags().deleteDeleted(tag.name) + if (db.tags().countByName(previousTag.name) == 1) { + db.tags().insertDeleted(previousTag.name, previousTag.color) } } + db.tags().update(tag.copy(status = Tag.STATUS_NORMAL)) } } @@ -81,6 +104,7 @@ interface TagsTable { companion object { const val NAME = "name" const val COLOR = "color" + const val STATUS = "status" } } @@ -88,16 +112,18 @@ interface TagsTable { const val BASE_ID = TABLE + "." + BaseColumns._ID const val NAME = "$TABLE.name" const val COLOR = "$TABLE.color" + const val STATUS = "$TABLE.status" } object Projection { const val BASE_ID = 0 const val NAME = 1 const val COLOR = 2 + const val STATUS = 3 } companion object { const val TABLE = "tags" - val projection = arrayOf(TableColumns.BASE_ID, TableColumns.NAME, TableColumns.COLOR) + val projection = arrayOf(TableColumns.BASE_ID, TableColumns.NAME, TableColumns.COLOR, TableColumns.STATUS) } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt deleted file mode 100644 index bf32a9df..00000000 --- a/app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.anod.appwatcher.database.entities - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import com.anod.appwatcher.database.DeletedTagsTable - -@Entity(tableName = DeletedTagsTable.TABLE) -data class DeletedTag( - @PrimaryKey - @ColumnInfo(name = DeletedTagsTable.Columns.NAME) - val name: String -) \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt index fb04f323..1b933d07 100644 --- a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt +++ b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt @@ -27,17 +27,19 @@ data class Tag( val name: String, @ColumnInfo(name = TagsTable.Columns.COLOR) @param:ColorInt - val color: Int + val color: Int, + @ColumnInfo(name = TagsTable.Columns.STATUS, defaultValue = "0") + val status: Int ) : Parcelable { val isEmpty: Boolean get() = id == 0 @Ignore - constructor(name: String) : this(0, name, DEFAULT_COLOR) + constructor(name: String) : this(0, name, DEFAULT_COLOR, STATUS_NORMAL) @Ignore - constructor(name: String, @ColorInt color: Int) : this(0, name, color) + constructor(name: String, @ColorInt color: Int) : this(0, name, color, STATUS_NORMAL) override fun equals(other: Any?): Boolean { if (other !is Tag) return false @@ -45,14 +47,17 @@ data class Tag( id != other.id -> false name != other.name -> false color != other.color -> false + status != other.status -> false else -> true } } - override fun hashCode() = hashCodeOf(name, color) + override fun hashCode() = hashCodeOf(name, color, status) companion object { + const val STATUS_NORMAL = 0 + const val STATUS_DELETED = 1 const val DEFAULT_COLOR = 0xFF2196F3.toInt() - val empty = Tag(0, "", DEFAULT_COLOR) + val empty = Tag(0, "", DEFAULT_COLOR, STATUS_NORMAL) } } \ No newline at end of file diff --git a/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt b/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt index 7b4b6ff8..d38cb15e 100644 --- a/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt +++ b/app/src/main/java/com/anod/appwatcher/tags/AppsTagDialog.kt @@ -144,7 +144,7 @@ private fun AppRow( @Preview @Composable private fun AppsTagScreenPreview() { - val tag = Tag(1, "Banana", Color.Magenta.toArgb()) + val tag = Tag(1, "Banana", Color.Magenta.toArgb(), Tag.STATUS_NORMAL) val appIconLoader = AppIconLoader.Simple( LocalContext.current, ImageLoader.Builder(LocalContext.current).build() ) diff --git a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt index ed031c85..4c2e43b3 100644 --- a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt +++ b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt @@ -8,7 +8,6 @@ import com.anod.appwatcher.database.AppListTable import com.anod.appwatcher.database.AppsDatabase import com.anod.appwatcher.database.TagsTable import com.anod.appwatcher.database.entities.App -import com.anod.appwatcher.database.entities.DeletedTag import com.anod.appwatcher.database.entities.Price import com.anod.appwatcher.database.entities.Tag import java.io.StringReader @@ -53,7 +52,8 @@ class GDriveTagSyncTest { assertTrue(db.tags().load().isEmpty()) assertTrue(db.appTags().load().isEmpty()) - assertEquals(listOf(tag.name), db.deletedTags().loadNames()) + assertEquals(listOf(tag.name), db.tags().loadDeletedNames()) + assertEquals(listOf(tag.id), db.tags().loadDeletedIds()) assertTrue(DbJsonReader().read(StringReader(writeDatabase())).tags.isEmpty()) } @@ -87,24 +87,25 @@ class GDriveTagSyncTest { @Test fun uploadAcknowledgesOnlyTombstonesPresentBeforeWriting() = runBlocking { - db.deletedTags().insert(DeletedTag("Uploaded")) - val uploadedTagNames = db.deletedTags().loadNames() - db.deletedTags().insert(DeletedTag("Deleted during upload")) + val uploadedTagId = db.tags().insertDeleted("Uploaded", Tag.DEFAULT_COLOR).toInt() + val uploadedTagIds = db.tags().loadDeletedIds() + db.tags().insertDeleted("Deleted during upload", Tag.DEFAULT_COLOR) - db.deletedTags().delete(uploadedTagNames) + db.tags().deleteDeleted(uploadedTagIds) - assertEquals(listOf("Deleted during upload"), db.deletedTags().loadNames()) + assertEquals(listOf(uploadedTagId), uploadedTagIds) + assertEquals(listOf("Deleted during upload"), db.tags().loadDeletedNames()) } @Test fun renamingTagTombstonesOldNameAndClearsNewNameTombstone() = runBlocking { val tag = insertTag("Old") - db.deletedTags().insert(DeletedTag("New")) + db.tags().insertDeleted("New", Tag.DEFAULT_COLOR) TagsTable.Queries.update(tag.copy(name = "New"), db) assertEquals(listOf("New"), db.tags().load().map { it.name }) - assertEquals(listOf("Old"), db.deletedTags().loadNames()) + assertEquals(listOf("Old"), db.tags().loadDeletedNames()) } private suspend fun insertTag(name: String): Tag { diff --git a/app/src/test/java/com/anod/appwatcher/compose/Material3TextFieldSurfacesTest.kt b/app/src/test/java/com/anod/appwatcher/compose/Material3TextFieldSurfacesTest.kt index b03fd438..ccda54fc 100644 --- a/app/src/test/java/com/anod/appwatcher/compose/Material3TextFieldSurfacesTest.kt +++ b/app/src/test/java/com/anod/appwatcher/compose/Material3TextFieldSurfacesTest.kt @@ -59,7 +59,9 @@ class Material3TextFieldSurfacesTest { compose.setContent { AppTheme(updateSystemBars = false) { EditTagScreen( - screenState = EditTagState(tag = Tag(id = 0, name = "", color = 0xFFE91E63.toInt())), + screenState = EditTagState( + tag = Tag(id = 0, name = "", color = 0xFFE91E63.toInt(), status = Tag.STATUS_NORMAL) + ), onEvent = { } ) } From 358711b655ae18da83462aa4ac4d4066967517f8 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:53:22 +0300 Subject: [PATCH 4/7] Clear duplicate deleted tag rows When a tag is recreated, revive one matching row and remove any remaining deleted rows for that name so stale tombstones cannot suppress Drive assignments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../java/com/anod/appwatcher/database/TagsTable.kt | 1 + .../appwatcher/backup/gdrive/GDriveTagSyncTest.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt index 6f9aaf61..53f27dcc 100644 --- a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt @@ -82,6 +82,7 @@ interface TagsTable { val deletedTag = db.tags().loadDeletedByName(tag.name) if (deletedTag != null) { db.tags().update(deletedTag.copy(color = tag.color, status = Tag.STATUS_NORMAL)) + db.tags().deleteDeleted(tag.name) deletedTag.id.toLong() } else { db.tags().insert(tag.name, tag.color) diff --git a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt index 4c2e43b3..b24817aa 100644 --- a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt +++ b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt @@ -71,6 +71,18 @@ class GDriveTagSyncTest { assertEquals(listOf(recreatedTag), DbJsonReader().read(StringReader(writeDatabase())).tags) } + @Test + fun recreatedTagClearsDuplicateDeletedRows() = runBlocking { + val name = "Recreated" + db.tags().insertDeleted(name, 1) + db.tags().insertDeleted(name, 2) + + val recreatedTag = insertTag(name) + + assertEquals(listOf(recreatedTag), db.tags().load()) + assertTrue(db.tags().loadDeletedNames().isEmpty()) + } + @Test fun existingTagCreateAndUpdateMergeBehaviorIsPreserved() = runBlocking { val existingTag = insertTag("Existing") From 372b521b8c0bafda0a07f2c3a6d5f0649ca487ca Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:37:11 +0300 Subject: [PATCH 5/7] Clarify deleted tag cleanup Rename the deleted-row cleanup APIs and document why duplicate active tag names are physically removed instead of retained as deletion markers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt | 2 +- .../com/anod/appwatcher/backup/gdrive/GDriveUpload.kt | 2 +- .../main/java/com/anod/appwatcher/database/TagsTable.kt | 9 +++++---- .../anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt index 2c60f27c..fe72b547 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt @@ -70,7 +70,7 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a val numTags = db.appTags().clean() AppTagsTable.Queries.clean(db) val numDeletedTags = if (bytes > 0 && deletedTagIds.isNotEmpty()) { - db.tags().deleteDeleted(deletedTagIds) + db.tags().cleanDeleted(deletedTagIds) } else { 0 } diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt index d4ff7f96..a70e268a 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt @@ -49,7 +49,7 @@ class GDriveUpload(private val googleAccount: Account, private val context: Appl val numTags = db.appTags().clean() AppTagsTable.Queries.clean(db) val numDeletedTags = if (bytes > 0 && deletedTagIds.isNotEmpty()) { - db.tags().deleteDeleted(deletedTagIds) + db.tags().cleanDeleted(deletedTagIds) } else { 0 } diff --git a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt index 53f27dcc..99f542f8 100644 --- a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt @@ -48,10 +48,10 @@ interface TagsTable { suspend fun delete(tagId: Int) @Query("DELETE FROM $TABLE WHERE ${BaseColumns._ID} IN (:tagIds) AND ${Columns.STATUS} = ${Tag.STATUS_DELETED}") - suspend fun deleteDeleted(tagIds: List): Int + suspend fun cleanDeleted(tagIds: List): Int @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} = :name AND ${Columns.STATUS} = ${Tag.STATUS_DELETED}") - suspend fun deleteDeleted(name: String): Int + suspend fun cleanDeleted(name: String): Int @Query("UPDATE $TABLE SET ${Columns.STATUS} = :status WHERE ${BaseColumns._ID} = :tagId") suspend fun updateStatus(tagId: Int, status: Int): Int @@ -71,6 +71,7 @@ interface TagsTable { object Queries { suspend fun delete(tag: Tag, db: AppsDatabase) = db.withTransaction { db.appTags().delete(tag.id) + // Keep a name-level deletion marker only when no active duplicate represents that name. if (db.tags().countByName(tag.name) > 1) { db.tags().delete(tag.id) } else { @@ -82,7 +83,7 @@ interface TagsTable { val deletedTag = db.tags().loadDeletedByName(tag.name) if (deletedTag != null) { db.tags().update(deletedTag.copy(color = tag.color, status = Tag.STATUS_NORMAL)) - db.tags().deleteDeleted(tag.name) + db.tags().cleanDeleted(tag.name) deletedTag.id.toLong() } else { db.tags().insert(tag.name, tag.color) @@ -92,7 +93,7 @@ interface TagsTable { suspend fun update(tag: Tag, db: AppsDatabase) = db.withTransaction { val previousTag = db.tags().loadById(tag.id) if (previousTag != null && previousTag.name != tag.name) { - db.tags().deleteDeleted(tag.name) + db.tags().cleanDeleted(tag.name) if (db.tags().countByName(previousTag.name) == 1) { db.tags().insertDeleted(previousTag.name, previousTag.color) } diff --git a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt index b24817aa..6955982d 100644 --- a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt +++ b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt @@ -103,7 +103,7 @@ class GDriveTagSyncTest { val uploadedTagIds = db.tags().loadDeletedIds() db.tags().insertDeleted("Deleted during upload", Tag.DEFAULT_COLOR) - db.tags().deleteDeleted(uploadedTagIds) + db.tags().cleanDeleted(uploadedTagIds) assertEquals(listOf(uploadedTagId), uploadedTagIds) assertEquals(listOf("Deleted during upload"), db.tags().loadDeletedNames()) From b924245bec175d82adea64688e470df0941ad292 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:53:09 +0300 Subject: [PATCH 6/7] Preserve serialized tag compatibility Default missing tag status to normal so Navigation 3 can restore back stacks saved before the status field was introduced. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../java/com/anod/appwatcher/database/entities/Tag.kt | 2 +- .../anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt index 1b933d07..97f1c1ef 100644 --- a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt +++ b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt @@ -29,7 +29,7 @@ data class Tag( @param:ColorInt val color: Int, @ColumnInfo(name = TagsTable.Columns.STATUS, defaultValue = "0") - val status: Int + val status: Int = STATUS_NORMAL ) : Parcelable { val isEmpty: Boolean diff --git a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt index 6955982d..47e2aa52 100644 --- a/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt +++ b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt @@ -13,6 +13,7 @@ import com.anod.appwatcher.database.entities.Tag import java.io.StringReader import java.io.StringWriter import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -40,6 +41,13 @@ class GDriveTagSyncTest { db.close() } + @Test + fun tagWithoutStatusRestoresAsNormal() { + val tag = Json.decodeFromString("""{"id":1,"name":"Saved","color":2}""") + + assertEquals(Tag(id = 1, name = "Saved", color = 2), tag) + } + @Test fun deletedTagIsNotRestoredAndIsExcludedFromNextUpload() = runBlocking { val tag = insertTag("Deleted") From 6b8e808d5e19a1877e286f2e8b328fc8b02a4195 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:06:04 +0300 Subject: [PATCH 7/7] Close Drive readers on merge failures Scope the Drive reader so parse exceptions cannot leak its temp-file descriptor, and include tag IDs in hashes to mirror equality. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0 --- .../main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt | 4 +++- .../main/java/com/anod/appwatcher/database/entities/Tag.kt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt index fe72b547..9f9f236d 100644 --- a/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt +++ b/app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt @@ -80,7 +80,9 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a @Throws(Exception::class) private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) { val reader = file.read() ?: throw IllegalStateException("Cannot read file") - insertRemoteItems(reader, db) + reader.use { + insertRemoteItems(it, db) + } } companion object { diff --git a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt index 97f1c1ef..ec31d344 100644 --- a/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt +++ b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt @@ -52,7 +52,7 @@ data class Tag( } } - override fun hashCode() = hashCodeOf(name, color, status) + override fun hashCode() = hashCodeOf(id, name, color, status) companion object { const val STATUS_NORMAL = 0