Propagate local tag deletions to Google Drive - #165
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0
There was a problem hiding this comment.
🟢 Approval recommended
The changes are coherent across DB/migration/sync paths and are backed by focused regression tests, with only a minor efficiency nit noted.
Pull request overview
Implements tag-deletion propagation semantics for Google Drive backup/sync by introducing a local “deleted tag name” tombstone table that prevents stale remote tags and assignments from being re-merged until an upload is acknowledged (Issue #163).
Changes:
- Add
deleted_tagsRoom table/entity, wire it intoAppsDatabasev20 with migration + schema export. - Tombstone tag names on local delete/rename and clear tombstones on recreate/rename to ensure correct merge semantics.
- Update Google Drive sync/upload to clear acknowledged tombstones after a successful write, and add focused unit regression coverage.
File summaries
| File | Description |
|---|---|
| app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt | Adds regression tests for tombstoning, stale-remote merge suppression, and tombstone acknowledgement behavior. |
| app/src/main/java/com/anod/appwatcher/tags/EditTagViewModel.kt | Routes tag updates through TagsTable.Queries.update so rename/update flows maintain tombstones correctly. |
| app/src/main/java/com/anod/appwatcher/database/TagsTable.kt | Adds tombstone-aware tag insert/update/delete logic and a countByName helper to avoid incorrect tombstoning when duplicates exist. |
| app/src/main/java/com/anod/appwatcher/database/entities/DeletedTag.kt | Introduces DeletedTag entity keyed by tag name. |
| app/src/main/java/com/anod/appwatcher/database/DeletedTagsTable.kt | Adds DAO for loading/inserting/deleting deleted-tag-name tombstones. |
| app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt | Bumps DB to v20, adds DeletedTag entity + deletedTags() DAO, and provides migration 19→20. |
| app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveUpload.kt | Captures and clears deleted-tag tombstones only after a successful Drive write. |
| app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt | Prevents stale remote tags/app-assignments from being merged if tombstoned; clears acknowledged tombstones post-upload. |
| app/src/main/java/com/anod/appwatcher/backup/DbBackupManager.kt | Clears tombstones on full restore reset to avoid carrying deleted-tag state across restores. |
| app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json | Exports Room schema v20 including the new deleted_tags table. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a41cc6b5-b40a-471d-b859-e382092b8ac0
There was a problem hiding this comment.
🔵 Needs a closer look
TagsTable.Queries.update() can clear a deleted-tag tombstone even when the tag row is missing (0-row update), which can re-enable stale Drive restoration after concurrent deletion.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
app/src/main/java/com/anod/appwatcher/database/TagsTable.kt:72
Queries.update()unconditionally clears the tombstone fortag.nameeven if the tag row no longer exists (e.g., the tag was deleted concurrently while the edit screen is open). In that casedb.tags().update(tag)affects 0 rows butdb.deletedTags().delete(tag.name)will still run, which can allow the deleted tag name to be restored from stale Drive data on a later merge.
val previousTag = db.tags().loadById(tag.id)
db.tags().update(tag)
db.deletedTags().delete(tag.name)
if (previousTag != null && previousTag.name != tag.name) {
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
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
There was a problem hiding this comment.
🟡 Changes recommended
Restoring a deleted tag by name can leave duplicate deleted rows for the same name, which can cause Drive merge to still treat the recreated name as deleted and skip restoring its remote app assignments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
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
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a hashCode/equals contract violation in Tag and leaves the Drive Reader unclosed during remote merge, both of which can cause correctness/resource issues.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt:55
Tag.equals()comparesid, buthashCode()does not includeid, which violates the equals/hashCode contract and can cause incorrect behavior whenTagis used in hashed collections (e.g.,Set,Map).
override fun hashCode() = hashCodeOf(name, color, status)
app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt:84
insertRemoteItems(file, db)obtains aReaderfromDriveIdFile.read()but never closes it, which can leak file descriptors/resources during sync. Wrap the reader inuse { ... }(or otherwise close it) after parsing completes.
private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) {
val reader = file.read() ?: throw IllegalStateException("Cannot read file")
insertRemoteItems(reader, db)
}
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Lite
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
There was a problem hiding this comment.
🟡 Changes recommended
It introduces a likely crash on Navigation 3 state restore due to a non-optional new Tag.status field and also leaves a newly introduced Reader unclosed during Drive merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
app/src/main/java/com/anod/appwatcher/backup/gdrive/GDriveSync.kt:84
DriveIdFile.read()returns aFileReaderbacked by a temp file; the current implementation passes it down without ever closing it. Over time this can leak file descriptors and keep temp files locked longer than necessary, especially if sync runs frequently or fails mid-parse.
private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) {
val reader = file.read() ?: throw IllegalStateException("Cannot read file")
insertRemoteItems(reader, db)
}
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
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
There was a problem hiding this comment.
🟡 Changes recommended
There are two correctness issues in the diff (an unclosed Drive Reader in sync merge and an equals/hashCode contract violation in Tag) that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt:55
equals()comparesid, buthashCode()does not include it. This violates the equals/hashCode contract and can cause incorrect behavior whenTagis used in hash-based collections (e.g., sets/maps) after this PR changes equality to also includestatus.
override fun hashCode() = hashCodeOf(name, color, status)
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
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
There was a problem hiding this comment.
🟢 Approval recommended
The tombstone-based deletion model is implemented consistently across Room queries, migrations, Drive sync/upload cleanup, and is backed by focused regression tests for the main stale-remote and recreation edge cases.
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Lite
Summary
Tag.status, matching the existing app deletion model without a separate tombstone tableCloses #163
Validation
./gradlew :app:testDebugUnitTest --tests "com.anod.appwatcher.backup.gdrive.GDriveTagSyncTest"./gradlew ktlintCheck