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..73a3e793 --- /dev/null +++ b/app/schemas/com.anod.appwatcher.database.AppsDatabase/20.json @@ -0,0 +1,336 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "5f512b5daa6022e167f0a9d6c437b984", + "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, `status` INTEGER NOT NULL DEFAULT 0, 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 + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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" + ] + } + } + ], + "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, '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/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 39308edc..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 @@ -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,82 @@ class GDriveSync(private val googleAccount: Account, private val context: info.a } } + val deletedTagIds = db.tags().loadDeletedIds() 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 && deletedTagIds.isNotEmpty()) { + db.tags().cleanDeleted(deletedTagIds) + } 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) { + private suspend fun insertRemoteItems(file: DriveIdFile, db: AppsDatabase) { val reader = file.read() ?: throw IllegalStateException("Cannot read file") + reader.use { + insertRemoteItems(it, 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.tags().loadDeletedNames().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, Tag.STATUS_NORMAL) + } } } - } - 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..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 @@ -40,15 +40,20 @@ class GDriveUpload(private val googleAccount: Account, private val context: Appl file.create() } + val deletedTagIds = db.tags().loadDeletedIds() 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 && deletedTagIds.isNotEmpty()) { + db.tags().cleanDeleted(deletedTagIds) + } 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..a37ba976 100644 --- a/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt +++ b/app/src/main/java/com/anod/appwatcher/database/AppsDatabase.kt @@ -29,7 +29,7 @@ abstract class AppsDatabase : RoomDatabase() { abstract fun schedules(): SchedulesTable 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 +193,12 @@ abstract class AppsDatabase : RoomDatabase() { } } + private val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE ${TagsTable.TABLE} ADD COLUMN ${TagsTable.Columns.STATUS} INTEGER NOT NULL DEFAULT ${Tag.STATUS_NORMAL}") + } + } + val migrations: Array = arrayOf( MIGRATION_9_11, MIGRATION_11_12, @@ -202,7 +208,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/TagsTable.kt b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt index 5c59b9b0..99f542f8 100644 --- a/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt +++ b/app/src/main/java/com/anod/appwatcher/database/TagsTable.kt @@ -1,7 +1,5 @@ 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 @@ -9,9 +7,7 @@ import androidx.room.Query import androidx.room.Update import androidx.room.withTransaction import com.anod.appwatcher.database.entities.Tag -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext /** * @author alex @@ -21,52 +17,88 @@ import kotlinx.coroutines.withContext @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 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 cleanDeleted(tagIds: List): Int + + @Query("DELETE FROM $TABLE WHERE ${Columns.NAME} = :name AND ${Columns.STATUS} = ${Tag.STATUS_DELETED}") + 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 + @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) + // 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 { + db.tags().updateStatus(tag.id, Tag.STATUS_DELETED) + } } - 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 deletedTag = db.tags().loadDeletedByName(tag.name) + if (deletedTag != null) { + db.tags().update(deletedTag.copy(color = tag.color, status = Tag.STATUS_NORMAL)) + db.tags().cleanDeleted(tag.name) + deletedTag.id.toLong() + } else { + db.tags().insert(tag.name, tag.color) } - var rowId = 0L - withContext(Dispatchers.IO) { - db.runInTransaction { - rowId = db.openHelper.writableDatabase.insert(TABLE, SQLiteDatabase.CONFLICT_REPLACE, values) + } + + 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().cleanDeleted(tag.name) + if (db.tags().countByName(previousTag.name) == 1) { + db.tags().insertDeleted(previousTag.name, previousTag.color) } } - return rowId + db.tags().update(tag.copy(status = Tag.STATUS_NORMAL)) } } @@ -74,6 +106,7 @@ interface TagsTable { companion object { const val NAME = "name" const val COLOR = "color" + const val STATUS = "status" } } @@ -81,16 +114,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/Tag.kt b/app/src/main/java/com/anod/appwatcher/database/entities/Tag.kt index fb04f323..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 @@ -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 = STATUS_NORMAL ) : 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(id, 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/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..47e2aa52 --- /dev/null +++ b/app/src/test/java/com/anod/appwatcher/backup/gdrive/GDriveTagSyncTest.kt @@ -0,0 +1,164 @@ +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.Price +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 +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 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") + 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.tags().loadDeletedNames()) + assertEquals(listOf(tag.id), db.tags().loadDeletedIds()) + 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 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") + 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 { + 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.tags().cleanDeleted(uploadedTagIds) + + assertEquals(listOf(uploadedTagId), uploadedTagIds) + assertEquals(listOf("Deleted during upload"), db.tags().loadDeletedNames()) + } + + @Test + fun renamingTagTombstonesOldNameAndClearsNewNameTombstone() = runBlocking { + val tag = insertTag("Old") + 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.tags().loadDeletedNames()) + } + + 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 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 = { } ) }