From 6d1bc3a341799c9a78724edcd0f56d70126cbf6a Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 2 Sep 2026 15:44:07 +0200 Subject: [PATCH 01/14] fix client writting server timestamp --- .../android/data/remote/firebase/protobuf/ModelToProtoExt.kt | 1 - .../android/data/remote/firebase/schema/LoiConverter.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt index dcc6d18416..8ba19c3672 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt @@ -156,7 +156,6 @@ private fun createAuditInfoMessage(user: User, timestamp: Long) = auditInfo { emailAddress = user.email photoUrl = user.photoUrl ?: photoUrl clientTimestamp = timestamp.toMessage() - serverTimestamp = timestamp.toMessage() } private fun Long.toMessage() = timestamp { seconds = this@toMessage / 1000 } diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt index f42c2131d9..0ccb18e6a3 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt @@ -54,7 +54,7 @@ object LoiConverter { // Degrade gracefully when audit info missing in remote db. val created = AuditInfoConverter.toAuditInfo(loiProto.created) val lastModified = - if (loiProto.lastModified != null) { + if (loiProto.hasLastModified()) { AuditInfoConverter.toAuditInfo(loiProto.lastModified) } else { created From 82be3b4d7a7413de22d89a33601373c8736844f6 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 2 Sep 2026 15:52:56 +0200 Subject: [PATCH 02/14] add new room table to track sync state --- .../129.json | 1177 +++++++++++++++++ .../android/common/Constants.kt | 2 +- .../android/data/local/room/LocalDatabase.kt | 6 + .../room/entity/SurveySyncStateEntity.kt | 40 + .../local/room/stores/RoomSyncStateStore.kt | 20 + 5 files changed, 1244 insertions(+), 1 deletion(-) create mode 100644 app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json create mode 100644 app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt create mode 100644 app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt diff --git a/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json b/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json new file mode 100644 index 0000000000..ef1e473257 --- /dev/null +++ b/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json @@ -0,0 +1,1177 @@ +{ + "formatVersion": 1, + "database": { + "version": 129, + "identityHash": "d29749b85ad91481ce460981e82740be", + "entities": [ + { + "tableName": "draft_submission", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `job_id` TEXT NOT NULL, `loi_id` TEXT, `survey_id` TEXT NOT NULL, `deltas` TEXT, `loi_name` TEXT, `current_task_id` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jobId", + "columnName": "job_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "loiId", + "columnName": "loi_id", + "affinity": "TEXT" + }, + { + "fieldPath": "surveyId", + "columnName": "survey_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deltas", + "columnName": "deltas", + "affinity": "TEXT" + }, + { + "fieldPath": "loiName", + "columnName": "loi_name", + "affinity": "TEXT" + }, + { + "fieldPath": "currentTaskId", + "columnName": "current_task_id", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_draft_submission_loi_id_job_id_survey_id", + "unique": false, + "columnNames": [ + "loi_id", + "job_id", + "survey_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_draft_submission_loi_id_job_id_survey_id` ON `${TABLE_NAME}` (`loi_id`, `job_id`, `survey_id`)" + } + ] + }, + { + "tableName": "location_of_interest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `survey_id` TEXT NOT NULL, `job_id` TEXT NOT NULL, `state` INTEGER NOT NULL, `geometry` BLOB, `customId` TEXT NOT NULL, `submissionCount` INTEGER NOT NULL, `properties` TEXT NOT NULL, `isPredefined` INTEGER, `created_clientTimestamp` INTEGER NOT NULL, `created_serverTimestamp` INTEGER, `created_user_id` TEXT NOT NULL, `created_user_email` TEXT NOT NULL, `created_user_display_name` TEXT NOT NULL, `modified_clientTimestamp` INTEGER NOT NULL, `modified_serverTimestamp` INTEGER, `modified_user_id` TEXT NOT NULL, `modified_user_email` TEXT NOT NULL, `modified_user_display_name` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`job_id`) REFERENCES `job`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "surveyId", + "columnName": "survey_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jobId", + "columnName": "job_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletionState", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "geometry", + "columnName": "geometry", + "affinity": "BLOB" + }, + { + "fieldPath": "customId", + "columnName": "customId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionCount", + "columnName": "submissionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "properties", + "columnName": "properties", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPredefined", + "columnName": "isPredefined", + "affinity": "INTEGER" + }, + { + "fieldPath": "created.clientTimestamp", + "columnName": "created_clientTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created.serverTimestamp", + "columnName": "created_serverTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "created.user.id", + "columnName": "created_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created.user.email", + "columnName": "created_user_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created.user.displayName", + "columnName": "created_user_display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified.clientTimestamp", + "columnName": "modified_clientTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified.serverTimestamp", + "columnName": "modified_serverTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified.user.id", + "columnName": "modified_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified.user.email", + "columnName": "modified_user_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified.user.displayName", + "columnName": "modified_user_display_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_location_of_interest_survey_id", + "unique": false, + "columnNames": [ + "survey_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_location_of_interest_survey_id` ON `${TABLE_NAME}` (`survey_id`)" + }, + { + "name": "index_location_of_interest_job_id", + "unique": false, + "columnNames": [ + "job_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_location_of_interest_job_id` ON `${TABLE_NAME}` (`job_id`)" + } + ], + "foreignKeys": [ + { + "table": "job", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "job_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "location_of_interest_mutation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `survey_id` TEXT NOT NULL, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `retry_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `user_id` TEXT NOT NULL, `client_timestamp` INTEGER NOT NULL, `location_of_interest_id` TEXT NOT NULL, `job_id` TEXT NOT NULL, `is_predefined` INTEGER, `collection_id` TEXT NOT NULL, `newGeometry` BLOB, `newProperties` TEXT NOT NULL, `newCustomId` TEXT NOT NULL, FOREIGN KEY(`location_of_interest_id`) REFERENCES `location_of_interest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "surveyId", + "columnName": "survey_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "last_error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTimestamp", + "columnName": "client_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "locationOfInterestId", + "columnName": "location_of_interest_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jobId", + "columnName": "job_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isPredefined", + "columnName": "is_predefined", + "affinity": "INTEGER" + }, + { + "fieldPath": "collectionId", + "columnName": "collection_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "newGeometry", + "columnName": "newGeometry", + "affinity": "BLOB" + }, + { + "fieldPath": "newProperties", + "columnName": "newProperties", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "newCustomId", + "columnName": "newCustomId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_location_of_interest_mutation_location_of_interest_id", + "unique": false, + "columnNames": [ + "location_of_interest_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_location_of_interest_mutation_location_of_interest_id` ON `${TABLE_NAME}` (`location_of_interest_id`)" + } + ], + "foreignKeys": [ + { + "table": "location_of_interest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_of_interest_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `index` INTEGER NOT NULL, `task_type` INTEGER NOT NULL, `label` TEXT, `is_required` INTEGER NOT NULL, `job_id` TEXT, `is_add_loi_task` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`job_id`) REFERENCES `job`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskType", + "columnName": "task_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "isRequired", + "columnName": "is_required", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jobId", + "columnName": "job_id", + "affinity": "TEXT" + }, + { + "fieldPath": "isAddLoiTask", + "columnName": "is_add_loi_task", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_job_id", + "unique": false, + "columnNames": [ + "job_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_job_id` ON `${TABLE_NAME}` (`job_id`)" + } + ], + "foreignKeys": [ + { + "table": "job", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "job_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "job", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `survey_id` TEXT, `strategy` TEXT NOT NULL, `style_color` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`survey_id`) REFERENCES `survey`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "surveyId", + "columnName": "survey_id", + "affinity": "TEXT" + }, + { + "fieldPath": "strategy", + "columnName": "strategy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "style.color", + "columnName": "style_color", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_job_survey_id", + "unique": false, + "columnNames": [ + "survey_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_job_survey_id` ON `${TABLE_NAME}` (`survey_id`)" + } + ], + "foreignKeys": [ + { + "table": "survey", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "survey_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "multiple_choice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`task_id` TEXT NOT NULL, `type` INTEGER NOT NULL, `has_other_option` INTEGER NOT NULL, PRIMARY KEY(`task_id`), FOREIGN KEY(`task_id`) REFERENCES `task`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasOtherOption", + "columnName": "has_other_option", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "task_id" + ] + }, + "indices": [ + { + "name": "index_multiple_choice_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_multiple_choice_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "task", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "option", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `code` TEXT NOT NULL, `label` TEXT NOT NULL, `task_id` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`task_id`) REFERENCES `task`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_option_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_option_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "task", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "survey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `acl` TEXT, `data_sharing_terms` BLOB, `general_access` INTEGER NOT NULL, `data_visibility` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "acl", + "columnName": "acl", + "affinity": "TEXT" + }, + { + "fieldPath": "dataSharingTerms", + "columnName": "data_sharing_terms", + "affinity": "BLOB" + }, + { + "fieldPath": "generalAccess", + "columnName": "general_access", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dataVisibility", + "columnName": "data_visibility", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "submission", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `location_of_interest_id` TEXT NOT NULL, `job_id` TEXT NOT NULL, `state` INTEGER NOT NULL, `data` TEXT, `created_clientTimestamp` INTEGER NOT NULL, `created_serverTimestamp` INTEGER, `created_user_id` TEXT NOT NULL, `created_user_email` TEXT NOT NULL, `created_user_display_name` TEXT NOT NULL, `modified_clientTimestamp` INTEGER NOT NULL, `modified_serverTimestamp` INTEGER, `modified_user_id` TEXT NOT NULL, `modified_user_email` TEXT NOT NULL, `modified_user_display_name` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`location_of_interest_id`) REFERENCES `location_of_interest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationOfInterestId", + "columnName": "location_of_interest_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jobId", + "columnName": "job_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletionState", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT" + }, + { + "fieldPath": "created.clientTimestamp", + "columnName": "created_clientTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created.serverTimestamp", + "columnName": "created_serverTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "created.user.id", + "columnName": "created_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created.user.email", + "columnName": "created_user_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created.user.displayName", + "columnName": "created_user_display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified.clientTimestamp", + "columnName": "modified_clientTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastModified.serverTimestamp", + "columnName": "modified_serverTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified.user.id", + "columnName": "modified_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified.user.email", + "columnName": "modified_user_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified.user.displayName", + "columnName": "modified_user_display_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_submission_location_of_interest_id_job_id_state", + "unique": false, + "columnNames": [ + "location_of_interest_id", + "job_id", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submission_location_of_interest_id_job_id_state` ON `${TABLE_NAME}` (`location_of_interest_id`, `job_id`, `state`)" + } + ], + "foreignKeys": [ + { + "table": "location_of_interest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_of_interest_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "submission_mutation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `survey_id` TEXT NOT NULL, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `retry_count` INTEGER NOT NULL, `last_error` TEXT NOT NULL, `user_id` TEXT NOT NULL, `client_timestamp` INTEGER NOT NULL, `location_of_interest_id` TEXT NOT NULL, `job_id` TEXT NOT NULL, `submission_id` TEXT NOT NULL, `collection_id` TEXT NOT NULL, `deltas` TEXT, FOREIGN KEY(`location_of_interest_id`) REFERENCES `location_of_interest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`submission_id`) REFERENCES `submission`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "surveyId", + "columnName": "survey_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStatus", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "last_error", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientTimestamp", + "columnName": "client_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "locationOfInterestId", + "columnName": "location_of_interest_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jobId", + "columnName": "job_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submission_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collection_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deltas", + "columnName": "deltas", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_submission_mutation_location_of_interest_id", + "unique": false, + "columnNames": [ + "location_of_interest_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submission_mutation_location_of_interest_id` ON `${TABLE_NAME}` (`location_of_interest_id`)" + }, + { + "name": "index_submission_mutation_submission_id", + "unique": false, + "columnNames": [ + "submission_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_submission_mutation_submission_id` ON `${TABLE_NAME}` (`submission_id`)" + } + ], + "foreignKeys": [ + { + "table": "location_of_interest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_of_interest_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "submission", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "submission_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "offline_area", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `state` INTEGER NOT NULL, `north` REAL NOT NULL, `south` REAL NOT NULL, `east` REAL NOT NULL, `west` REAL NOT NULL, `min_zoom` INTEGER NOT NULL, `max_zoom` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "north", + "columnName": "north", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "south", + "columnName": "south", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "east", + "columnName": "east", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "west", + "columnName": "west", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "minZoom", + "columnName": "min_zoom", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxZoom", + "columnName": "max_zoom", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `display_name` TEXT NOT NULL, `photo_url` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUrl", + "columnName": "photo_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "condition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parent_task_id` TEXT NOT NULL, `match_type` INTEGER NOT NULL, PRIMARY KEY(`parent_task_id`), FOREIGN KEY(`parent_task_id`) REFERENCES `task`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "parentTaskId", + "columnName": "parent_task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "matchType", + "columnName": "match_type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "parent_task_id" + ] + }, + "indices": [ + { + "name": "index_condition_parent_task_id", + "unique": false, + "columnNames": [ + "parent_task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_condition_parent_task_id` ON `${TABLE_NAME}` (`parent_task_id`)" + } + ], + "foreignKeys": [ + { + "table": "task", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "expression", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parent_task_id` TEXT NOT NULL, `task_id` TEXT NOT NULL, `expression_type` INTEGER NOT NULL, `option_ids` TEXT, `other_selected` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`parent_task_id`), FOREIGN KEY(`parent_task_id`) REFERENCES `condition`(`parent_task_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "parentTaskId", + "columnName": "parent_task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expressionType", + "columnName": "expression_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "optionIds", + "columnName": "option_ids", + "affinity": "TEXT" + }, + { + "fieldPath": "otherSelected", + "columnName": "other_selected", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "parent_task_id" + ] + }, + "indices": [ + { + "name": "index_expression_parent_task_id", + "unique": false, + "columnNames": [ + "parent_task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_expression_parent_task_id` ON `${TABLE_NAME}` (`parent_task_id`)" + } + ], + "foreignKeys": [ + { + "table": "condition", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_task_id" + ], + "referencedColumns": [ + "parent_task_id" + ] + } + ] + }, + { + "tableName": "survey_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`survey_id` TEXT NOT NULL, `latest_loi_server_timestamp` INTEGER NOT NULL, `last_full_sync_client_timestamp` INTEGER NOT NULL, `synced_data_visibility` TEXT NOT NULL, PRIMARY KEY(`survey_id`), FOREIGN KEY(`survey_id`) REFERENCES `survey`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "surveyId", + "columnName": "survey_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latestLoiServerTimestamp", + "columnName": "latest_loi_server_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastFullSyncClientTimestamp", + "columnName": "last_full_sync_client_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedDataVisibility", + "columnName": "synced_data_visibility", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "survey_id" + ] + }, + "foreignKeys": [ + { + "table": "survey", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "survey_id" + ], + "referencedColumns": [ + "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, 'd29749b85ad91481ce460981e82740be')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/groundplatform/android/common/Constants.kt b/app/src/main/java/org/groundplatform/android/common/Constants.kt index 32248e6c88..41b8c0e7be 100644 --- a/app/src/main/java/org/groundplatform/android/common/Constants.kt +++ b/app/src/main/java/org/groundplatform/android/common/Constants.kt @@ -25,7 +25,7 @@ object Constants { const val SHARED_PREFS_MODE = Context.MODE_PRIVATE // Local db settings. - const val DB_VERSION = 128 + const val DB_VERSION = 129 const val DB_NAME = "ground.db" // Firebase Cloud Firestore settings. diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/LocalDatabase.kt b/app/src/main/java/org/groundplatform/android/data/local/room/LocalDatabase.kt index 4f6722fa9e..bbdfb8a8b0 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/LocalDatabase.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/LocalDatabase.kt @@ -37,6 +37,7 @@ import org.groundplatform.android.data.local.room.dao.OptionDao import org.groundplatform.android.data.local.room.dao.SubmissionDao import org.groundplatform.android.data.local.room.dao.SubmissionMutationDao import org.groundplatform.android.data.local.room.dao.SurveyDao +import org.groundplatform.android.data.local.room.dao.SurveySyncStateDao import org.groundplatform.android.data.local.room.dao.TaskDao import org.groundplatform.android.data.local.room.dao.UserDao import org.groundplatform.android.data.local.room.entity.ConditionEntity @@ -51,6 +52,7 @@ import org.groundplatform.android.data.local.room.entity.OptionEntity import org.groundplatform.android.data.local.room.entity.SubmissionEntity import org.groundplatform.android.data.local.room.entity.SubmissionMutationEntity import org.groundplatform.android.data.local.room.entity.SurveyEntity +import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity import org.groundplatform.android.data.local.room.entity.TaskEntity import org.groundplatform.android.data.local.room.entity.UserEntity import org.groundplatform.android.data.local.room.fields.EntityDeletionState @@ -87,6 +89,7 @@ import org.groundplatform.android.data.local.room.fields.TileSetEntityState UserEntity::class, ConditionEntity::class, ExpressionEntity::class, + SurveySyncStateEntity::class, ], version = Constants.DB_VERSION, exportSchema = true, @@ -97,6 +100,7 @@ import org.groundplatform.android.data.local.room.fields.TileSetEntityState AutoMigration(from = 122, to = 123), AutoMigration(from = 123, to = 124), AutoMigration(from = 127, to = 128), + AutoMigration(from = 128, to = 129), ], ) @TypeConverters( @@ -143,4 +147,6 @@ abstract class LocalDatabase : RoomDatabase() { abstract fun conditionDao(): ConditionDao abstract fun expressionDao(): ExpressionDao + + abstract fun surveySyncStateDao(): SurveySyncStateDao } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt b/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt new file mode 100644 index 0000000000..7c3c06dff9 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.data.local.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "survey_sync_state", + foreignKeys = + [ + ForeignKey( + entity = SurveyEntity::class, + parentColumns = ["id"], + childColumns = ["survey_id"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class SurveySyncStateEntity( + @ColumnInfo(name = "survey_id") @PrimaryKey val surveyId: String, + @ColumnInfo(name = "latest_loi_server_timestamp") val latestLoiServerTimestamp: Long, + @ColumnInfo(name = "last_full_sync_client_timestamp") val lastFullSyncClientTimestamp: Long, + @ColumnInfo(name = "synced_data_visibility") val syncedDataVisibility: String, +) diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt new file mode 100644 index 0000000000..7859e80996 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.data.local.room.stores + +class RoomSyncStateStore { + +} \ No newline at end of file From 5824953f4e05489119b5c4f455867db5c1461b1d Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 2 Sep 2026 16:13:23 +0200 Subject: [PATCH 03/14] add the local store for sync survey state --- .../data/local/room/converter/ConverterExt.kt | 23 ++++++++ .../data/local/room/dao/SurveySyncStateDao.kt | 29 ++++++++++ .../room/entity/SurveySyncStateEntity.kt | 2 +- .../room/stores/RoomSurveySyncStateStore.kt | 58 +++++++++++++++++++ .../local/stores/LocalSurveySyncStateStore.kt | 35 +++++++++++ .../android/di/LocalDataStoreModule.kt | 10 ++++ .../domain/model/SurveySyncState.kt | 11 ++-- 7 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt create mode 100644 app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt create mode 100644 app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt rename app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt => core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncState.kt (72%) diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt index ee1d5f814c..2b89c01111 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt @@ -35,6 +35,7 @@ import org.groundplatform.android.data.local.room.entity.StyleEntity import org.groundplatform.android.data.local.room.entity.SubmissionEntity import org.groundplatform.android.data.local.room.entity.SubmissionMutationEntity import org.groundplatform.android.data.local.room.entity.SurveyEntity +import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity import org.groundplatform.android.data.local.room.entity.TaskEntity import org.groundplatform.android.data.local.room.entity.UserEntity import org.groundplatform.android.data.local.room.fields.EntityDeletionState @@ -55,6 +56,7 @@ import org.groundplatform.android.data.remote.firebase.protobuf.toProto import org.groundplatform.android.proto.Survey as SurveyProto import org.groundplatform.android.proto.Survey.DataSharingTerms import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.geometry.Geometry @@ -410,9 +412,14 @@ fun SurveyEntityAndRelations.toModelObject(): Survey { ?.let { DataSharingTerms.parseFrom(surveyEntity.dataSharingTerms) } ?.toModel(), surveyEntity.generalAccess.toGeneralAccess(), + surveyEntity.dataVisibility?.toDataVisibility() ) } +fun Int.toDataVisibility(): Survey.DataVisibility = + SurveyProto.DataVisibility.entries.find { it.number == this }?.toModel() + ?: Survey.DataVisibility.UNSPECIFIED + fun Int.toGeneralAccess(): Survey.GeneralAccess = SurveyProto.GeneralAccess.entries.find { it.number == this }?.toModel() ?: Survey.GeneralAccess.UNRECOGNIZED @@ -434,6 +441,22 @@ fun Survey.toLocalDataStoreObject() = dataVisibility = dataVisibility?.toProto()?.ordinal, ) +fun SurveySyncStateEntity.toModelObject(): SurveySyncState = + SurveySyncState( + surveyId = surveyId, + lastFullSyncClientTimestamp = lastFullSyncClientTimestamp, + latestLoiServerTimestamp = latestLoiServerTimestamp, + syncedDataVisibility = syncedDataVisibility?.toDataVisibility(), + ) + +fun SurveySyncState.toLocalDataStoreObject() = + SurveySyncStateEntity( + surveyId = surveyId, + lastFullSyncClientTimestamp = lastFullSyncClientTimestamp, + latestLoiServerTimestamp = latestLoiServerTimestamp, + syncedDataVisibility = syncedDataVisibility?.toProto()?.ordinal, + ) + fun Task.toLocalDataStoreObject(jobId: String?) = TaskEntity( id = id, diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt b/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt new file mode 100644 index 0000000000..d92bb91c7d --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.data.local.room.dao + +import androidx.room.Dao +import androidx.room.Query +import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity + +@Dao +interface SurveySyncStateDao : BaseDao { + @Query("SELECT * FROM survey_sync_state WHERE survey_id = :surveyId") + suspend fun get(surveyId: String): SurveySyncStateEntity? + + @Query("UPDATE survey_sync_state SET latest_loi_server_timestamp = :latestLoiServerTimestamp WHERE survey_id = :surveyId") + suspend fun updateLatestLoiServerTimestamp(surveyId: String, latestLoiServerTimestamp: Long) +} diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt b/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt index 7c3c06dff9..bc997ee90f 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/entity/SurveySyncStateEntity.kt @@ -36,5 +36,5 @@ data class SurveySyncStateEntity( @ColumnInfo(name = "survey_id") @PrimaryKey val surveyId: String, @ColumnInfo(name = "latest_loi_server_timestamp") val latestLoiServerTimestamp: Long, @ColumnInfo(name = "last_full_sync_client_timestamp") val lastFullSyncClientTimestamp: Long, - @ColumnInfo(name = "synced_data_visibility") val syncedDataVisibility: String, + @ColumnInfo(name = "synced_data_visibility") val syncedDataVisibility: Int?, ) diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt new file mode 100644 index 0000000000..716833a526 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.data.local.room.stores + +import javax.inject.Inject +import kotlin.time.Clock +import org.groundplatform.android.data.local.room.converter.toModelObject +import org.groundplatform.android.data.local.room.dao.SurveySyncStateDao +import org.groundplatform.android.data.local.room.dao.insertOrUpdate +import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity +import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore +import org.groundplatform.android.data.remote.firebase.protobuf.toProto +import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncState + +class RoomSurveySyncStateStore +@Inject +constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyncStateStore { + override suspend fun get(surveyId: String): SurveySyncState? { + val entity = surveySyncStateDao.get(surveyId) + return entity?.toModelObject() + } + + override suspend fun recordIncrementalSync( + surveyId: String, + latestLoiServerTimestamp: Long, + ) { + surveySyncStateDao.updateLatestLoiServerTimestamp(surveyId, latestLoiServerTimestamp) + } + + override suspend fun recordFullSync( + surveyId: String, + latestLoiServerTimestamp: Long, + dataVisibility: Survey.DataVisibility?, + ) { + surveySyncStateDao.insertOrUpdate( + SurveySyncStateEntity( + surveyId = surveyId, + latestLoiServerTimestamp = latestLoiServerTimestamp, + lastFullSyncClientTimestamp = Clock.System.now().toEpochMilliseconds(), + syncedDataVisibility = dataVisibility?.toProto()?.ordinal, + ) + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt new file mode 100644 index 0000000000..25da4eef0a --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.data.local.stores + +import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity +import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncState + +interface LocalSurveySyncStateStore { + suspend fun get(surveyId: String): SurveySyncState? + + suspend fun recordIncrementalSync( + surveyId: String, + latestLoiServerTimestamp: Long, + ) + + suspend fun recordFullSync( + surveyId: String, + latestLoiServerTimestamp: Long, + dataVisibility: Survey.DataVisibility?, + ) +} diff --git a/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt b/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt index 729e3928a7..4d50779969 100644 --- a/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt @@ -34,17 +34,20 @@ import org.groundplatform.android.data.local.room.dao.OptionDao import org.groundplatform.android.data.local.room.dao.SubmissionDao import org.groundplatform.android.data.local.room.dao.SubmissionMutationDao import org.groundplatform.android.data.local.room.dao.SurveyDao +import org.groundplatform.android.data.local.room.dao.SurveySyncStateDao import org.groundplatform.android.data.local.room.dao.TaskDao import org.groundplatform.android.data.local.room.dao.UserDao import org.groundplatform.android.data.local.room.stores.RoomLocationOfInterestStore import org.groundplatform.android.data.local.room.stores.RoomOfflineAreaStore import org.groundplatform.android.data.local.room.stores.RoomSubmissionStore import org.groundplatform.android.data.local.room.stores.RoomSurveyStore +import org.groundplatform.android.data.local.room.stores.RoomSurveySyncStateStore import org.groundplatform.android.data.local.room.stores.RoomUserStore import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore import org.groundplatform.android.data.local.stores.LocalOfflineAreaStore import org.groundplatform.android.data.local.stores.LocalSubmissionStore import org.groundplatform.android.data.local.stores.LocalSurveyStore +import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore import org.groundplatform.android.data.local.stores.LocalUserStore @InstallIn(SingletonComponent::class) @@ -67,6 +70,8 @@ abstract class LocalDataStoreModule { @Binds @Singleton abstract fun userStore(store: RoomUserStore): LocalUserStore + @Binds @Singleton abstract fun surveySyncStateStore(store: RoomSurveySyncStateStore): LocalSurveySyncStateStore + companion object { @Provides fun draftSubmissionDao(localDatabase: LocalDatabase): DraftSubmissionDao { @@ -137,5 +142,10 @@ abstract class LocalDataStoreModule { fun expressionDao(localDatabase: LocalDatabase): ExpressionDao { return localDatabase.expressionDao() } + + @Provides + fun surveySyncStoreDao(localDatabase: LocalDatabase): SurveySyncStateDao { + return localDatabase.surveySyncStateDao() + } } } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncState.kt similarity index 72% rename from app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt rename to core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncState.kt index 7859e80996..3bda9b96e3 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSyncStateStore.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncState.kt @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.android.data.local.room.stores +package org.groundplatform.domain.model -class RoomSyncStateStore { - -} \ No newline at end of file +data class SurveySyncState( + val surveyId: String, + val latestLoiServerTimestamp: Long, + val lastFullSyncClientTimestamp: Long, + val syncedDataVisibility: Survey.DataVisibility?, +) From f55abefd3547970b35d407b8eb33480ddd746861 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 2 Sep 2026 18:21:37 +0200 Subject: [PATCH 04/14] read payload from the firebase message and pass along the survey sync code --- .../firebase/FirebaseMessagingService.kt | 2 +- .../android/data/sync/SurveySyncService.kt | 4 +- .../android/data/sync/SurveySyncWorker.kt | 11 ++- .../LocationOfInterestRepository.kt | 97 +++++++++++++------ .../LocationOfInterestRepositoryInterface.kt | 2 +- .../usecases/survey/SyncSurveyUseCase.kt | 10 +- 6 files changed, 84 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt index 75cf2c1c3b..1c3ccabe51 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt @@ -45,7 +45,7 @@ class FirebaseMessagingService : FirebaseMessagingService() { return } Timber.v("Message received from topic ${remoteMessage.from}") - surveySyncService.enqueueSync(surveyId) + surveySyncService.enqueueSync(surveyId, remoteMessage.data["deleted"].toBoolean()) } override fun onNewToken(token: String) { diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt index e48c62f986..98663e7d5e 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt @@ -30,8 +30,8 @@ class SurveySyncService @Inject constructor(private val workManager: WorkManager * * @return The id of the worker request, used in tests to retrieve the worker status. */ - fun enqueueSync(surveyId: String): UUID { - val inputData = SurveySyncWorker.createInputData(surveyId) + fun enqueueSync(surveyId: String, forceFullSync: Boolean): UUID { + val inputData = SurveySyncWorker.createInputData(surveyId, forceFullSync) val request = WorkRequestBuilder() .setWorkerClass(SurveySyncWorker::class.java) diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt index 9b4370c7d0..ec109e3f1d 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt @@ -49,6 +49,7 @@ constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(context, params) { private val surveyId: String? = params.inputData.getString(SURVEY_ID_PARAM_KEY) + private val forceFullSync: Boolean = params.inputData.getBoolean(FORCE_FULL_SYNC_PARAM_KEY, false) override suspend fun doWork(): Result = withContext(ioDispatcher) { doWorkInternal() } @@ -72,7 +73,7 @@ constructor( surveyRepository.unsubscribeFromSurveyUpdates(surveyId) } else { Timber.d("Syncing survey $surveyId") - syncSurvey(surveyId) + syncSurvey(surveyId, forceFullSync) } } success() @@ -101,6 +102,7 @@ constructor( companion object { /** The key in worker input data containing the id of the survey to be synced. */ internal const val SURVEY_ID_PARAM_KEY = "surveyId" + internal const val FORCE_FULL_SYNC_PARAM_KEY = "forceFullSync" /** * How many times to run a sync before giving up. Each run re-reads every location of interest @@ -115,7 +117,10 @@ constructor( internal const val SYNC_TIMEOUT_MILLIS = 8 * 60 * 1000L /** Returns a new work [Data] object containing the specified survey id. */ - fun createInputData(surveyId: String): Data = - Data.Builder().putString(SURVEY_ID_PARAM_KEY, surveyId).build() + fun createInputData(surveyId: String, forceFullSync: Boolean): Data = + Data.Builder() + .putString(SURVEY_ID_PARAM_KEY, surveyId) + .putBoolean(FORCE_FULL_SYNC_PARAM_KEY, forceFullSync) + .build() } } diff --git a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index b4d123adb8..92fa435a67 100644 --- a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt @@ -17,18 +17,22 @@ package org.groundplatform.android.repository import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore import org.groundplatform.android.data.local.stores.LocalSurveyStore +import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager import org.groundplatform.android.data.uuid.OfflineUuidGenerator import org.groundplatform.android.system.auth.AuthenticationManager import org.groundplatform.domain.model.Role import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.geometry.Geometry import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest @@ -57,46 +61,74 @@ constructor( private val userRepository: UserRepositoryInterface, private val uuidGenerator: OfflineUuidGenerator, private val authenticationManager: AuthenticationManager, + private val localSurveySyncStateStore: LocalSurveySyncStateStore, ) : LocationOfInterestRepositoryInterface { - override suspend fun syncLocationsOfInterest(survey: Survey) { + override suspend fun syncLocationsOfInterest(survey: Survey, forceFullSync: Boolean) { val ownerUserId = authenticationManager.getAuthenticatedUser().id + val currentSurveySyncState = localSurveySyncStateStore.get(survey.id) + val syncFromTimestamp = + getSyncStartTimestamp( + survey = survey, + currentSurveySyncState = currentSurveySyncState, + forceFullSync = forceFullSync, + ) // Single-page buffering. Persist immediately to avoid OOM on geometry-heavy surveys. val syncedLoiIds = mutableSetOf() - syncedLoiIds += savePages(remoteDataStore.loadPredefinedLois(survey)) - // Shared LOIs are visible to all survey participants, so a user's own LOIs are already - // included. - syncedLoiIds += - if (survey.dataVisibility == Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) { - savePages(remoteDataStore.loadSharedLois(survey)) - } else { - savePages(remoteDataStore.loadUserLois(survey, ownerUserId)) + var newestLoiTimestamp = syncFromTimestamp ?: 0L + + suspend fun savePages(pages: Flow>) { + pages.collect { page -> + localLoiStore.insertOrUpdateAll(page) + syncedLoiIds += page.map { it.id } + newestLoiTimestamp = + maxOf( + newestLoiTimestamp, + page.maxOfOrNull { it.lastModified.serverTimestamp ?: 0L } ?: 0L, + ) } + } + savePages(remoteDataStore.loadPredefinedLois(survey, syncFromTimestamp)) + if (survey.dataVisibility == Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) { + savePages(remoteDataStore.loadSharedLois(survey, syncFromTimestamp)) + } else { + savePages(remoteDataStore.loadUserLois(survey, ownerUserId, syncFromTimestamp)) + } - val mutations = localLoiStore.getAllSurveyMutations(survey).firstOrNull().orEmpty() - - // NOTE(#2652): Don't delete pending locations of interest, since we can accidentally delete - // them here if we get to this routine before they can be synced up to the remote database. - val pendingLois = - mutations - .asSequence() - .filter { it.syncStatus in setOf(SyncStatus.PENDING, SyncStatus.IN_PROGRESS) } - .map { it.locationOfInterestId } - .toList() - - // Delete LOIs in local db not returned in latest list from server, skipping pending mutations. - localLoiStore.deleteNotIn(survey.id, syncedLoiIds.toList() + pendingLois) + if (syncFromTimestamp == null) { + val mutations = localLoiStore.getAllSurveyMutations(survey).firstOrNull().orEmpty() + + // NOTE(#2652): Don't delete pending locations of interest, since we can accidentally delete + // them here if we get to this routine before they can be synced up to the remote database. + val pendingLois = + mutations + .asSequence() + .filter { it.syncStatus in setOf(SyncStatus.PENDING, SyncStatus.IN_PROGRESS) } + .map { it.locationOfInterestId } + .toList() + + // Delete LOIs in local db not returned in latest list from server, skipping pending + // mutations. + localLoiStore.deleteNotIn(survey.id, syncedLoiIds.toList() + pendingLois) + localSurveySyncStateStore.recordFullSync(survey.id, newestLoiTimestamp, survey.dataVisibility) + } else { + localSurveySyncStateStore.recordIncrementalSync(survey.id, newestLoiTimestamp) + } } - /** Saves each page of [pages] as it arrives, returning the ids of every LOI saved. */ - private suspend fun savePages(pages: Flow>): Set { - val savedIds = mutableSetOf() - pages.collect { page -> - localLoiStore.insertOrUpdateAll(page) - savedIds += page.map { it.id } + private fun getSyncStartTimestamp( + survey: Survey, + currentSurveySyncState: SurveySyncState?, + forceFullSync: Boolean, + ): Long? = + when { + forceFullSync -> null + currentSurveySyncState == null -> null + survey.dataVisibility != currentSurveySyncState.syncedDataVisibility -> null + Clock.System.now().toEpochMilliseconds() - + currentSurveySyncState.lastFullSyncClientTimestamp > FULL_SYNC_INTERVAL_MILLIS -> null + else -> currentSurveySyncState.latestLoiServerTimestamp } - return savedIds - } override suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest? { val survey = localSurveyStore.getSurveyById(surveyId) @@ -215,4 +247,9 @@ constructor( val mutation = loi.toMutation(Mutation.Type.DELETE, user.id) applyAndEnqueue(mutation) } + + companion object { + // An undelivered FCM is stored for a max of 28 days + private val FULL_SYNC_INTERVAL_MILLIS = 28.days.inWholeMilliseconds + } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt index 3e057c04af..6dfc29f1d7 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt @@ -25,7 +25,7 @@ import org.groundplatform.domain.model.mutation.LocationOfInterestMutation interface LocationOfInterestRepositoryInterface { /** Mirrors locations of interest in the specified survey from the remote db into the local db. */ - suspend fun syncLocationsOfInterest(survey: Survey) + suspend fun syncLocationsOfInterest(survey: Survey, forceFullSync: Boolean) /** This only works if the survey and location of interests are already cached to local db. */ suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest? diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt index c0c14d9722..b245711c50 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt @@ -33,17 +33,17 @@ class SyncSurveyUseCase( private val surveyRepository: SurveyRepositoryInterface, ) { - suspend operator fun invoke(surveyId: String): Survey? = - fetchSurvey(surveyId)?.also { syncSurvey(it) } + suspend operator fun invoke(surveyId: String, forceFullSync: Boolean): Survey? = + fetchSurvey(surveyId)?.also { syncSurvey(it, forceFullSync) } private suspend fun fetchSurvey(surveyId: String): Survey? { Logger.d("Loading survey $surveyId") return surveyRepository.getRemoteSurvey(surveyId) } - private suspend fun syncSurvey(survey: Survey) { + private suspend fun syncSurvey(survey: Survey, forceFullSync: Boolean) { surveyRepository.saveSurvey(survey) - loiRepository.syncLocationsOfInterest(survey) - Logger.d("Synced survey ${survey.id}") + loiRepository.syncLocationsOfInterest(survey, forceFullSync) + Logger.d("Synced survey ${survey.id}, forceFullSync=$forceFullSync") } } From 791973f029e4a98f71b1fdeb73a58cba9087aed6 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 2 Sep 2026 18:33:16 +0200 Subject: [PATCH 05/14] fixup! add new room table to track sync state --- .../129.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json b/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json index ef1e473257..9f3aceaae6 100644 --- a/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json +++ b/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 129, - "identityHash": "d29749b85ad91481ce460981e82740be", + "identityHash": "fe1334c01a767da11a41a4bf7878daaf", "entities": [ { "tableName": "draft_submission", @@ -1121,7 +1121,7 @@ }, { "tableName": "survey_sync_state", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`survey_id` TEXT NOT NULL, `latest_loi_server_timestamp` INTEGER NOT NULL, `last_full_sync_client_timestamp` INTEGER NOT NULL, `synced_data_visibility` TEXT NOT NULL, PRIMARY KEY(`survey_id`), FOREIGN KEY(`survey_id`) REFERENCES `survey`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`survey_id` TEXT NOT NULL, `latest_loi_server_timestamp` INTEGER NOT NULL, `last_full_sync_client_timestamp` INTEGER NOT NULL, `synced_data_visibility` INTEGER, PRIMARY KEY(`survey_id`), FOREIGN KEY(`survey_id`) REFERENCES `survey`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "surveyId", @@ -1144,8 +1144,7 @@ { "fieldPath": "syncedDataVisibility", "columnName": "synced_data_visibility", - "affinity": "TEXT", - "notNull": true + "affinity": "INTEGER" } ], "primaryKey": { @@ -1171,7 +1170,7 @@ ], "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, 'd29749b85ad91481ce460981e82740be')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fe1334c01a767da11a41a4bf7878daaf')" ] } } \ No newline at end of file From dbe96ba847ce38d6912f88b5457e219cf7d9012b Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 2 Sep 2026 19:07:18 +0200 Subject: [PATCH 06/14] implement incremental survey load based on FCM payload --- .../android/data/remote/RemoteDataStore.kt | 10 +++-- .../remote/firebase/FirestoreDataStore.kt | 11 ++--- .../firebase/schema/LoiCollectionReference.kt | 42 +++++++++++++++---- .../MakeSurveyAvailableOfflineUseCase.kt | 2 +- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt index b791d91934..5687235e11 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt @@ -50,15 +50,19 @@ interface RemoteDataStore { suspend fun loadTermsOfService(): TermsOfService? /** Returns predefined LOIs in the specified survey. Main-safe. */ - fun loadPredefinedLois(survey: Survey): Flow> + fun loadPredefinedLois(survey: Survey, fromTimestamp: Long?): Flow> /** Returns LOIs owned by the specified user in the specified survey. Main-safe. */ - fun loadUserLois(survey: Survey, ownerUserId: String): Flow> + fun loadUserLois( + survey: Survey, + ownerUserId: String, + fromTimestamp: Long?, + ): Flow> /** * Returns LOIs that have been marked as shared for other participants of the specified survey. */ - fun loadSharedLois(survey: Survey): Flow> + fun loadSharedLois(survey: Survey, fromTimestamp: Long?): Flow> /** * Applies the provided mutations to the remote data store in a single batched transaction. If one diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt index 1c7addd08d..faecb631f4 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt @@ -84,13 +84,14 @@ internal constructor( ) } - override fun loadPredefinedLois(survey: Survey) = - fetchLoiPages(survey) { fetchPredefined(survey) } + override fun loadPredefinedLois(survey: Survey, fromTimestamp: Long?) = + fetchLoiPages(survey) { fetchPredefined(survey, fromTimestamp) } - override fun loadUserLois(survey: Survey, ownerUserId: String) = - fetchLoiPages(survey) { fetchUserDefined(survey, ownerUserId) } + override fun loadUserLois(survey: Survey, ownerUserId: String, fromTimestamp: Long?) = + fetchLoiPages(survey) { fetchUserDefined(survey, ownerUserId, fromTimestamp) } - override fun loadSharedLois(survey: Survey) = fetchLoiPages(survey) { fetchSharedLois(survey) } + override fun loadSharedLois(survey: Survey, fromTimestamp: Long?) = + fetchLoiPages(survey) { fetchSharedLois(survey, fromTimestamp) } /** Emits the pages of LOIs produced by [fetch] against the given survey's LOI collection. */ private fun fetchLoiPages( diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt index 0490ccf9cf..02bb75831d 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt @@ -21,11 +21,13 @@ import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FieldPath import com.google.firebase.firestore.Query +import com.google.protobuf.Timestamp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import org.groundplatform.android.data.remote.firebase.base.FluentCollectionReference import org.groundplatform.android.data.remote.firebase.schema.LoiConverter.toLoi +import org.groundplatform.android.proto.AuditInfo import org.groundplatform.android.proto.LocationOfInterest as LocationOfInterestProto import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.locationofinterest.LocationOfInterest @@ -38,6 +40,13 @@ import timber.log.Timber const val SOURCE_FIELD = LocationOfInterestProto.SOURCE_FIELD_NUMBER.toString() /** Path of field on LOI documents representing the creator of the LOI. */ const val OWNER_FIELD = LocationOfInterestProto.OWNER_ID_FIELD_NUMBER.toString() +/** Path of field on LOI documents representing the last modified server timestamp. */ +private val LAST_MODIFIED_SERVER_SECONDS: FieldPath = + FieldPath.of( + LocationOfInterestProto.LAST_MODIFIED_FIELD_NUMBER.toString(), + AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER.toString(), + Timestamp.SECONDS_FIELD_NUMBER.toString(), + ) /** * Documents per query. Deliberately small since geometry complexity varies widely and is unknown @@ -51,39 +60,58 @@ class LoiCollectionReference internal constructor(ref: CollectionReference) : fun loi(id: String) = LoiDocumentReference(reference().document(id)) /** Emits all "predefined" LOIs in the specified survey, one page at a time. Main-safe. */ - fun fetchPredefined(survey: Survey): Flow> = + fun fetchPredefined(survey: Survey, fromTimestamp: Long?): Flow> = // Use !=false rather than ==true to not break legacy dev surveys. // TODO: Switch to whereEqualTo(true) once legacy dev surveys deleted or migrated. // Issue URL: https://github.com/google/ground-android/issues/2375 fetchLois( survey, reference().whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.IMPORTED.number), + fromTimestamp, ) /** Emits LOIs created by the specified email in the specified survey, a page at a time. */ - fun fetchUserDefined(survey: Survey, ownerUserId: String): Flow> = + fun fetchUserDefined( + survey: Survey, + ownerUserId: String, + fromTimestamp: Long?, + ): Flow> = fetchLois( survey, reference() .whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.FIELD_DATA.number) .whereEqualTo(OWNER_FIELD, ownerUserId), + fromTimestamp, ) /** Emits all LOIs visible to data collectors in the given survey, a page at a time. */ - fun fetchSharedLois(survey: Survey): Flow> = + fun fetchSharedLois(survey: Survey, fromTimestamp: Long?): Flow> = fetchLois( survey, reference().whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.FIELD_DATA.number), + fromTimestamp, ) /** * Emits the LOIs matching [query], a page at a time. Pages are fetched lazily, so a collector * that saves each page before asking for the next never holds more than one page in memory. */ - private fun fetchLois(survey: Survey, query: Query): Flow> = flow { - val orderedQuery = query.orderBy(FieldPath.documentId()).limit(PAGE_SIZE.toLong()) + private fun fetchLois( + survey: Survey, + query: Query, + fromTimestamp: Long?, + ): Flow> = flow { + val orderedQuery = + if (fromTimestamp == null) { + query.orderBy(FieldPath.documentId()).limit(PAGE_SIZE.toLong()) + } else { + query + .whereGreaterThan(LAST_MODIFIED_SERVER_SECONDS, fromTimestamp / 1000) + .orderBy(LAST_MODIFIED_SERVER_SECONDS) + .limit(PAGE_SIZE.toLong()) + } - var startAfter: String? = null + var startAfter: DocumentSnapshot? = null var hasMore: Boolean do { @@ -97,7 +125,7 @@ class LoiCollectionReference internal constructor(ref: CollectionReference) : // Counted in documents fetched, not LOIs emitted: an unreadable document is dropped by the // conversion above but still takes up a place in the page. hasMore = documents.size == PAGE_SIZE - startAfter = documents.last().id + startAfter = documents.last() } while (hasMore) } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt index 4de83a2934..27a5176904 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt @@ -31,5 +31,5 @@ class MakeSurveyAvailableOfflineUseCase( private val syncSurvey: SyncSurveyUseCase, ) { suspend operator fun invoke(surveyId: String): Survey? = - syncSurvey(surveyId)?.also { surveyRepository.subscribeToSurveyUpdates(surveyId) } + syncSurvey(surveyId = surveyId, forceFullSync = true)?.also { surveyRepository.subscribeToSurveyUpdates(surveyId) } } From 69977d5953f2e9cc27c5194a3fc2fdee66675289 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 4 Sep 2026 12:16:34 +0200 Subject: [PATCH 07/14] implement check and record sync state through survey repository --- .../android/repository/SurveyRepository.kt | 21 +++++++++++++++++++ .../repository/SurveyRepositoryInterface.kt | 10 +++++++++ 2 files changed, 31 insertions(+) diff --git a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt index 0539a2edc4..fbe058255f 100644 --- a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt @@ -34,11 +34,15 @@ import kotlinx.coroutines.withTimeout import org.groundplatform.android.FirebaseCrashLogger import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.local.stores.LocalSurveyStore +import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.di.coroutines.ApplicationScope import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.SurveySyncState +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.User +import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import timber.log.Timber @@ -53,6 +57,7 @@ constructor( @ApplicationScope private val externalScope: CoroutineScope, private val firebaseCrashLogger: FirebaseCrashLogger, private val localSurveyStore: LocalSurveyStore, + private val localSurveySyncStateStore: LocalSurveySyncStateStore, private val localValueStore: LocalValueStore, private val remoteDataStore: RemoteDataStore, ) : SurveyRepositoryInterface { @@ -86,6 +91,22 @@ constructor( override fun getOfflineSurveys(): Flow> = localSurveyStore.surveys + override suspend fun getSyncState(surveyId: String): SurveySyncState? = + localSurveySyncStateStore.get(surveyId) + + override suspend fun recordSyncState(survey: Survey, loiSyncResult: LocationOfInterestRepositoryInterface.SyncResult) { + when (loiSyncResult.mode) { + is SurveySyncMode.Full -> + localSurveySyncStateStore.recordFullSync( + survey.id, + loiSyncResult.latestLoiServerTimestamp, + survey.dataVisibility, + ) + is SurveySyncMode.Incremental -> + localSurveySyncStateStore.recordIncrementalSync(survey.id, loiSyncResult.latestLoiServerTimestamp) + } + } + override suspend fun removeOfflineSurvey(surveyId: String) { getOfflineSurvey(surveyId)?.let { localSurveyStore.deleteSurvey(it) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt index b2daa0daeb..80d40b97d8 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User /** Maintains the state of currently active survey. */ @@ -32,6 +33,15 @@ interface SurveyRepositoryInterface { suspend fun saveSurvey(survey: Survey) + /** Returns what the last sync of the given survey left behind, or null if none has run. */ + suspend fun getSyncState(surveyId: String): SurveySyncState? + + /** Records where [loiSyncResult] left the sync of [survey], for the next one to resume from. */ + suspend fun recordSyncState( + survey: Survey, + loiSyncResult: LocationOfInterestRepositoryInterface.SyncResult, + ) + suspend fun getRemoteSurvey(surveyId: String): Survey? fun getRemoteSurveys(user: User): Flow> From 63ec5ee04db927867e230ef17f9742de336dd966 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 4 Sep 2026 13:49:24 +0200 Subject: [PATCH 08/14] add method to count LOIs from remote --- .../android/data/remote/RemoteDataStore.kt | 3 + .../remote/firebase/FirestoreDataStore.kt | 24 ++++++- .../firebase/schema/LoiCollectionReference.kt | 71 ++++++++++--------- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt index 5687235e11..b4d6b27d9e 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt @@ -64,6 +64,9 @@ interface RemoteDataStore { */ fun loadSharedLois(survey: Survey, fromTimestamp: Long?): Flow> + /** Returns how many LOIs a sync of the specified survey would fetch. Main-safe. */ + suspend fun countLois(survey: Survey, ownerUserId: String): Long + /** * Applies the provided mutations to the remote data store in a single batched transaction. If one * update fails, none of the mutations will be applied. diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt index faecb631f4..2af652440a 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt @@ -23,6 +23,8 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.emitAll @@ -35,6 +37,7 @@ import org.groundplatform.android.BuildConfig.USE_EMULATORS import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.data.remote.firebase.schema.GroundFirestore import org.groundplatform.android.data.remote.firebase.schema.LoiCollectionReference +import org.groundplatform.android.data.remote.firebase.schema.LoiQueryScope import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem @@ -85,13 +88,28 @@ internal constructor( } override fun loadPredefinedLois(survey: Survey, fromTimestamp: Long?) = - fetchLoiPages(survey) { fetchPredefined(survey, fromTimestamp) } + fetchLoiPages(survey) { fetch(survey, LoiQueryScope.Predefined, fromTimestamp) } override fun loadUserLois(survey: Survey, ownerUserId: String, fromTimestamp: Long?) = - fetchLoiPages(survey) { fetchUserDefined(survey, ownerUserId, fromTimestamp) } + fetchLoiPages(survey) { fetch(survey, LoiQueryScope.UserDefined(ownerUserId), fromTimestamp) } override fun loadSharedLois(survey: Survey, fromTimestamp: Long?) = - fetchLoiPages(survey) { fetchSharedLois(survey, fromTimestamp) } + fetchLoiPages(survey) { fetch(survey, LoiQueryScope.Shared, fromTimestamp) } + + override suspend fun countLois(survey: Survey, ownerUserId: String): Long = + withContext(ioDispatcher) { + val lois = db().surveys().survey(survey.id).lois() + val fieldData = + if (survey.dataVisibility == Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) { + LoiQueryScope.Shared + } else { + LoiQueryScope.UserDefined(ownerUserId) + } + // A round trip each, and neither needs the other's answer. + listOf(async { lois.count(LoiQueryScope.Predefined) }, async { lois.count(fieldData) }) + .awaitAll() + .sum() + } /** Emits the pages of LOIs produced by [fetch] against the given survey's LOI collection. */ private fun fetchLoiPages( diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt index 02bb75831d..be1e874812 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReference.kt @@ -17,6 +17,7 @@ package org.groundplatform.android.data.remote.firebase.schema import androidx.annotation.VisibleForTesting +import com.google.firebase.firestore.AggregateSource import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FieldPath @@ -41,7 +42,8 @@ const val SOURCE_FIELD = LocationOfInterestProto.SOURCE_FIELD_NUMBER.toString() /** Path of field on LOI documents representing the creator of the LOI. */ const val OWNER_FIELD = LocationOfInterestProto.OWNER_ID_FIELD_NUMBER.toString() /** Path of field on LOI documents representing the last modified server timestamp. */ -private val LAST_MODIFIED_SERVER_SECONDS: FieldPath = +@VisibleForTesting +internal val LAST_MODIFIED_SERVER_SECONDS: FieldPath = FieldPath.of( LocationOfInterestProto.LAST_MODIFIED_FIELD_NUMBER.toString(), AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER.toString(), @@ -54,43 +56,48 @@ private val LAST_MODIFIED_SERVER_SECONDS: FieldPath = */ @VisibleForTesting internal const val PAGE_SIZE = 250 +internal sealed interface LoiQueryScope { + val source: LocationOfInterestProto.Source + val ownerUserId: String? + + data object Predefined : LoiQueryScope { + // Use !=false rather than ==true to not break legacy dev surveys. + // TODO: Switch to whereEqualTo(true) once legacy dev surveys deleted or migrated. + // Issue URL: https://github.com/google/ground-android/issues/2375 + override val source = LocationOfInterestProto.Source.IMPORTED + override val ownerUserId: String? = null + } + + data object Shared : LoiQueryScope { + override val source = LocationOfInterestProto.Source.FIELD_DATA + override val ownerUserId: String? = null + } + + data class UserDefined(override val ownerUserId: String) : LoiQueryScope { + override val source = LocationOfInterestProto.Source.FIELD_DATA + } +} + class LoiCollectionReference internal constructor(ref: CollectionReference) : FluentCollectionReference(ref) { fun loi(id: String) = LoiDocumentReference(reference().document(id)) - /** Emits all "predefined" LOIs in the specified survey, one page at a time. Main-safe. */ - fun fetchPredefined(survey: Survey, fromTimestamp: Long?): Flow> = - // Use !=false rather than ==true to not break legacy dev surveys. - // TODO: Switch to whereEqualTo(true) once legacy dev surveys deleted or migrated. - // Issue URL: https://github.com/google/ground-android/issues/2375 - fetchLois( - survey, - reference().whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.IMPORTED.number), - fromTimestamp, - ) - - /** Emits LOIs created by the specified email in the specified survey, a page at a time. */ - fun fetchUserDefined( + /** Emits the survey's LOIs in [scope], one page at a time. */ + internal fun fetch( survey: Survey, - ownerUserId: String, + scope: LoiQueryScope, fromTimestamp: Long?, - ): Flow> = - fetchLois( - survey, - reference() - .whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.FIELD_DATA.number) - .whereEqualTo(OWNER_FIELD, ownerUserId), - fromTimestamp, - ) - - /** Emits all LOIs visible to data collectors in the given survey, a page at a time. */ - fun fetchSharedLois(survey: Survey, fromTimestamp: Long?): Flow> = - fetchLois( - survey, - reference().whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.FIELD_DATA.number), - fromTimestamp, - ) + ): Flow> = fetchLois(survey, query(scope), fromTimestamp) + + /** Returns how many LOIs [fetch] would emit for the same [scope]. */ + internal suspend fun count(scope: LoiQueryScope): Long = + query(scope).count().get(AggregateSource.SERVER).await().count + + private fun query(scope: LoiQueryScope): Query { + val query = reference().whereEqualTo(SOURCE_FIELD, scope.source.number) + return scope.ownerUserId?.let { query.whereEqualTo(OWNER_FIELD, it) } ?: query + } /** * Emits the LOIs matching [query], a page at a time. Pages are fetched lazily, so a collector @@ -106,7 +113,7 @@ class LoiCollectionReference internal constructor(ref: CollectionReference) : query.orderBy(FieldPath.documentId()).limit(PAGE_SIZE.toLong()) } else { query - .whereGreaterThan(LAST_MODIFIED_SERVER_SECONDS, fromTimestamp / 1000) + .whereGreaterThanOrEqualTo(LAST_MODIFIED_SERVER_SECONDS, fromTimestamp / 1000) .orderBy(LAST_MODIFIED_SERVER_SECONDS) .limit(PAGE_SIZE.toLong()) } From 5b0761a2cba6d3d679b902f4e225e0e7fbc12227 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 4 Sep 2026 14:18:06 +0200 Subject: [PATCH 09/14] update incremental read approach: move decision about full/partial sync to SyncSurveyUseCase; add count check to know if a full sync should be performed --- .../data/local/room/converter/ConverterExt.kt | 2 +- .../room/dao/LocationOfInterestMutationDao.kt | 11 +++ .../stores/RoomLocationOfInterestStore.kt | 13 ++- .../firebase/FirebaseMessagingService.kt | 3 +- .../android/data/sync/SurveySyncService.kt | 7 +- .../android/data/sync/SurveySyncWorker.kt | 13 +-- .../android/di/LocalDataStoreModule.kt | 4 +- .../LocationOfInterestRepository.kt | 95 +++++++++---------- .../android/repository/SurveyRepository.kt | 12 ++- .../domain/model/SurveySyncMode.kt | 23 +++++ .../LocationOfInterestRepositoryInterface.kt | 10 +- .../MakeSurveyAvailableOfflineUseCase.kt | 2 +- .../usecases/survey/SyncSurveyUseCase.kt | 30 +++++- 13 files changed, 150 insertions(+), 75 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncMode.kt diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt index 2b89c01111..44fe4f27f7 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt @@ -412,7 +412,7 @@ fun SurveyEntityAndRelations.toModelObject(): Survey { ?.let { DataSharingTerms.parseFrom(surveyEntity.dataSharingTerms) } ?.toModel(), surveyEntity.generalAccess.toGeneralAccess(), - surveyEntity.dataVisibility?.toDataVisibility() + surveyEntity.dataVisibility?.toDataVisibility(), ) } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt b/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt index 03db3d0d39..577cd08564 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt @@ -39,4 +39,15 @@ interface LocationOfInterestMutationDao : BaseDao + + /** Returns the IDs of the survey's LOIs which have a mutation in one of the given states. */ + @Query( + "SELECT DISTINCT location_of_interest_id FROM location_of_interest_mutation " + + "WHERE survey_id = :surveyId " + + "AND state IN (:allowedStates)" + ) + suspend fun getLocationOfInterestIds( + surveyId: String, + vararg allowedStates: MutationEntitySyncStatus, + ): List } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt index d34b17853b..41ebe76abe 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt @@ -146,9 +146,20 @@ class RoomLocationOfInterestStore @Inject internal constructor() : LocalLocation override suspend fun deleteNotIn(surveyId: String, ids: List) { val idsToKeep = ids.toSet() localDatabase.withTransaction { + // NOTE(#2652): Never delete an LOI with unsynced changes, including one saved while the + // caller was still fetching. Dropping it here would take its queued mutation along with it, + // since mutations cascade on the LOI they point at. + val pendingIds = + locationOfInterestMutationDao + .getLocationOfInterestIds( + surveyId, + MutationEntitySyncStatus.PENDING, + MutationEntitySyncStatus.IN_PROGRESS, + ) + .toSet() locationOfInterestDao .getIds(surveyId) - .filterNot { it in idsToKeep } + .filterNot { it in idsToKeep || it in pendingIds } .chunked(MAX_SQL_VARIABLES) .forEach { locationOfInterestDao.deleteByIds(it) } } diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt index 1c3ccabe51..af9708d2c1 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt @@ -45,7 +45,8 @@ class FirebaseMessagingService : FirebaseMessagingService() { return } Timber.v("Message received from topic ${remoteMessage.from}") - surveySyncService.enqueueSync(surveyId, remoteMessage.data["deleted"].toBoolean()) + + surveySyncService.enqueueSync(surveyId) } override fun onNewToken(token: String) { diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt index 98663e7d5e..a5b9244156 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.android.data.sync +import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager import java.util.UUID @@ -30,8 +31,10 @@ class SurveySyncService @Inject constructor(private val workManager: WorkManager * * @return The id of the worker request, used in tests to retrieve the worker status. */ - fun enqueueSync(surveyId: String, forceFullSync: Boolean): UUID { - val inputData = SurveySyncWorker.createInputData(surveyId, forceFullSync) + fun enqueueSync(surveyId: String): UUID = + enqueue(surveyId, SurveySyncWorker.createInputData(surveyId)) + + private fun enqueue(surveyId: String, inputData: Data): UUID { val request = WorkRequestBuilder() .setWorkerClass(SurveySyncWorker::class.java) diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt index ec109e3f1d..1bde87faa0 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt @@ -49,7 +49,6 @@ constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(context, params) { private val surveyId: String? = params.inputData.getString(SURVEY_ID_PARAM_KEY) - private val forceFullSync: Boolean = params.inputData.getBoolean(FORCE_FULL_SYNC_PARAM_KEY, false) override suspend fun doWork(): Result = withContext(ioDispatcher) { doWorkInternal() } @@ -73,7 +72,7 @@ constructor( surveyRepository.unsubscribeFromSurveyUpdates(surveyId) } else { Timber.d("Syncing survey $surveyId") - syncSurvey(surveyId, forceFullSync) + syncSurvey(surveyId) } } success() @@ -102,7 +101,6 @@ constructor( companion object { /** The key in worker input data containing the id of the survey to be synced. */ internal const val SURVEY_ID_PARAM_KEY = "surveyId" - internal const val FORCE_FULL_SYNC_PARAM_KEY = "forceFullSync" /** * How many times to run a sync before giving up. Each run re-reads every location of interest @@ -116,11 +114,8 @@ constructor( */ internal const val SYNC_TIMEOUT_MILLIS = 8 * 60 * 1000L - /** Returns a new work [Data] object containing the specified survey id. */ - fun createInputData(surveyId: String, forceFullSync: Boolean): Data = - Data.Builder() - .putString(SURVEY_ID_PARAM_KEY, surveyId) - .putBoolean(FORCE_FULL_SYNC_PARAM_KEY, forceFullSync) - .build() + /** Returns a new work [Data] object requesting a sync of the specified survey. */ + fun createInputData(surveyId: String): Data = + Data.Builder().putString(SURVEY_ID_PARAM_KEY, surveyId).build() } } diff --git a/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt b/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt index 4d50779969..d368d21c9a 100644 --- a/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt @@ -70,7 +70,9 @@ abstract class LocalDataStoreModule { @Binds @Singleton abstract fun userStore(store: RoomUserStore): LocalUserStore - @Binds @Singleton abstract fun surveySyncStateStore(store: RoomSurveySyncStateStore): LocalSurveySyncStateStore + @Binds + @Singleton + abstract fun surveySyncStateStore(store: RoomSurveySyncStateStore): LocalSurveySyncStateStore companion object { @Provides diff --git a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index 92fa435a67..7f87a2f0c6 100644 --- a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt @@ -17,22 +17,19 @@ package org.groundplatform.android.repository import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.Clock -import kotlin.time.Duration.Companion.days import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore import org.groundplatform.android.data.local.stores.LocalSurveyStore -import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager import org.groundplatform.android.data.uuid.OfflineUuidGenerator import org.groundplatform.android.system.auth.AuthenticationManager import org.groundplatform.domain.model.Role import org.groundplatform.domain.model.Survey -import org.groundplatform.domain.model.SurveySyncState +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.geometry.Geometry import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest @@ -61,18 +58,30 @@ constructor( private val userRepository: UserRepositoryInterface, private val uuidGenerator: OfflineUuidGenerator, private val authenticationManager: AuthenticationManager, - private val localSurveySyncStateStore: LocalSurveySyncStateStore, ) : LocationOfInterestRepositoryInterface { - override suspend fun syncLocationsOfInterest(survey: Survey, forceFullSync: Boolean) { + override suspend fun syncLocationsOfInterest( + survey: Survey, + mode: SurveySyncMode, + ): LocationOfInterestRepositoryInterface.SyncResult { val ownerUserId = authenticationManager.getAuthenticatedUser().id - val currentSurveySyncState = localSurveySyncStateStore.get(survey.id) - val syncFromTimestamp = - getSyncStartTimestamp( - survey = survey, - currentSurveySyncState = currentSurveySyncState, - forceFullSync = forceFullSync, - ) + val missedDeletion = + mode is SurveySyncMode.Incremental && hasMissedDeletion(survey, ownerUserId) + if (missedDeletion) { + Timber.d("Reading all of survey ${survey.id}, local LOIs outnumber the remote ones") + } + + val effectiveMode = if (missedDeletion) SurveySyncMode.Full else mode + + return LocationOfInterestRepositoryInterface.SyncResult( + mode = effectiveMode, + latestLoiServerTimestamp = syncLois(survey, ownerUserId, effectiveMode), + ) + } + + /** Reads the survey's LOIs into the local db, returning the newest server timestamp it saw. */ + private suspend fun syncLois(survey: Survey, ownerUserId: String, mode: SurveySyncMode): Long { + val syncFromTimestamp = (mode as? SurveySyncMode.Incremental)?.fromTimestamp // Single-page buffering. Persist immediately to avoid OOM on geometry-heavy surveys. val syncedLoiIds = mutableSetOf() var newestLoiTimestamp = syncFromTimestamp ?: 0L @@ -95,40 +104,33 @@ constructor( savePages(remoteDataStore.loadUserLois(survey, ownerUserId, syncFromTimestamp)) } - if (syncFromTimestamp == null) { - val mutations = localLoiStore.getAllSurveyMutations(survey).firstOrNull().orEmpty() - - // NOTE(#2652): Don't delete pending locations of interest, since we can accidentally delete - // them here if we get to this routine before they can be synced up to the remote database. - val pendingLois = - mutations - .asSequence() - .filter { it.syncStatus in setOf(SyncStatus.PENDING, SyncStatus.IN_PROGRESS) } - .map { it.locationOfInterestId } - .toList() - - // Delete LOIs in local db not returned in latest list from server, skipping pending - // mutations. - localLoiStore.deleteNotIn(survey.id, syncedLoiIds.toList() + pendingLois) - localSurveySyncStateStore.recordFullSync(survey.id, newestLoiTimestamp, survey.dataVisibility) - } else { - localSurveySyncStateStore.recordIncrementalSync(survey.id, newestLoiTimestamp) + if (mode is SurveySyncMode.Full) { + // NOTE(#2652): Delete LOIs in local db not returned in latest list from server. The store + // keeps the ones with unsynced local changes, since dropping one would lose the mutation + // before it reaches the server. + localLoiStore.deleteNotIn(survey.id, syncedLoiIds.toList()) } + + return newestLoiTimestamp } - private fun getSyncStartTimestamp( - survey: Survey, - currentSurveySyncState: SurveySyncState?, - forceFullSync: Boolean, - ): Long? = - when { - forceFullSync -> null - currentSurveySyncState == null -> null - survey.dataVisibility != currentSurveySyncState.syncedDataVisibility -> null - Clock.System.now().toEpochMilliseconds() - - currentSurveySyncState.lastFullSyncClientTimestamp > FULL_SYNC_INTERVAL_MILLIS -> null - else -> currentSurveySyncState.latestLoiServerTimestamp - } + /** Returns whether the local db holds an LOI which a full sync would find gone from remote. */ + private suspend fun hasMissedDeletion(survey: Survey, ownerUserId: String): Boolean { + val mutations = localLoiStore.getAllSurveyMutations(survey).firstOrNull().orEmpty() + + // Number of distinct LOIs with a pending or in-progress local mutation that isn't a delete. + val pendingNonDeletedLoiCount = + mutations + .asSequence() + .filter { it.syncStatus in setOf(SyncStatus.PENDING, SyncStatus.IN_PROGRESS) } + .filterNot { it.type == Mutation.Type.DELETE } + .map { it.locationOfInterestId } + .distinct() + .count() + + return remoteDataStore.countLois(survey, ownerUserId) < + localLoiStore.getLoiCount(survey.id) - pendingNonDeletedLoiCount + } override suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest? { val survey = localSurveyStore.getSurveyById(surveyId) @@ -247,9 +249,4 @@ constructor( val mutation = loi.toMutation(Mutation.Type.DELETE, user.id) applyAndEnqueue(mutation) } - - companion object { - // An undelivered FCM is stored for a max of 28 days - private val FULL_SYNC_INTERVAL_MILLIS = 28.days.inWholeMilliseconds - } } diff --git a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt index fbe058255f..02a83071a5 100644 --- a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt @@ -39,8 +39,8 @@ import org.groundplatform.android.data.remote.RemoteDataStore import org.groundplatform.android.di.coroutines.ApplicationScope import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem -import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.SurveySyncMode +import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface @@ -94,7 +94,10 @@ constructor( override suspend fun getSyncState(surveyId: String): SurveySyncState? = localSurveySyncStateStore.get(surveyId) - override suspend fun recordSyncState(survey: Survey, loiSyncResult: LocationOfInterestRepositoryInterface.SyncResult) { + override suspend fun recordSyncState( + survey: Survey, + loiSyncResult: LocationOfInterestRepositoryInterface.SyncResult, + ) { when (loiSyncResult.mode) { is SurveySyncMode.Full -> localSurveySyncStateStore.recordFullSync( @@ -103,7 +106,10 @@ constructor( survey.dataVisibility, ) is SurveySyncMode.Incremental -> - localSurveySyncStateStore.recordIncrementalSync(survey.id, loiSyncResult.latestLoiServerTimestamp) + localSurveySyncStateStore.recordIncrementalSync( + survey.id, + loiSyncResult.latestLoiServerTimestamp, + ) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncMode.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncMode.kt new file mode 100644 index 0000000000..2cce694602 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncMode.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.domain.model + +/** How much of a survey's locations of interest the next sync should read. */ +sealed interface SurveySyncMode { + data object Full : SurveySyncMode + + data class Incremental(val fromTimestamp: Long) : SurveySyncMode +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt index 6dfc29f1d7..c306f9f6b9 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt @@ -17,6 +17,7 @@ package org.groundplatform.domain.repository import kotlinx.coroutines.flow.Flow import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.geometry.Geometry import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest @@ -24,8 +25,13 @@ import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.mutation.LocationOfInterestMutation interface LocationOfInterestRepositoryInterface { - /** Mirrors locations of interest in the specified survey from the remote db into the local db. */ - suspend fun syncLocationsOfInterest(survey: Survey, forceFullSync: Boolean) + data class SyncResult(val mode: SurveySyncMode, val latestLoiServerTimestamp: Long) + + /** + * Mirrors locations of interest in the specified survey from the remote db into the local db, + * reading as much of them as [mode] calls for, and returns where that left the sync. + */ + suspend fun syncLocationsOfInterest(survey: Survey, mode: SurveySyncMode): SyncResult /** This only works if the survey and location of interests are already cached to local db. */ suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest? diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt index 27a5176904..baeeef7c9f 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt @@ -31,5 +31,5 @@ class MakeSurveyAvailableOfflineUseCase( private val syncSurvey: SyncSurveyUseCase, ) { suspend operator fun invoke(surveyId: String): Survey? = - syncSurvey(surveyId = surveyId, forceFullSync = true)?.also { surveyRepository.subscribeToSurveyUpdates(surveyId) } + syncSurvey(surveyId = surveyId)?.also { surveyRepository.subscribeToSurveyUpdates(surveyId) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt index b245711c50..006626738c 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt @@ -16,7 +16,10 @@ package org.groundplatform.domain.usecases.survey import co.touchlab.kermit.Logger +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface @@ -33,17 +36,34 @@ class SyncSurveyUseCase( private val surveyRepository: SurveyRepositoryInterface, ) { - suspend operator fun invoke(surveyId: String, forceFullSync: Boolean): Survey? = - fetchSurvey(surveyId)?.also { syncSurvey(it, forceFullSync) } + suspend operator fun invoke(surveyId: String): Survey? = + fetchSurvey(surveyId)?.also { syncSurvey(it) } private suspend fun fetchSurvey(surveyId: String): Survey? { Logger.d("Loading survey $surveyId") return surveyRepository.getRemoteSurvey(surveyId) } - private suspend fun syncSurvey(survey: Survey, forceFullSync: Boolean) { + private suspend fun syncSurvey(survey: Survey) { surveyRepository.saveSurvey(survey) - loiRepository.syncLocationsOfInterest(survey, forceFullSync) - Logger.d("Synced survey ${survey.id}, forceFullSync=$forceFullSync") + val result = loiRepository.syncLocationsOfInterest(survey, syncMode(survey)) + surveyRepository.recordSyncState(survey, result) + Logger.d("Synced survey ${survey.id}") + } + + private suspend fun syncMode(survey: Survey): SurveySyncMode { + val syncState = surveyRepository.getSyncState(survey.id) + return when { + syncState == null -> SurveySyncMode.Full + survey.dataVisibility != syncState.syncedDataVisibility -> SurveySyncMode.Full + Clock.System.now().toEpochMilliseconds() - syncState.lastFullSyncClientTimestamp > + FULL_SYNC_INTERVAL_MILLIS -> SurveySyncMode.Full + else -> SurveySyncMode.Incremental(syncState.latestLoiServerTimestamp) + } + } + + private companion object { + // An undelivered FCM is stored for a max of 28 days + val FULL_SYNC_INTERVAL_MILLIS = 28.days.inWholeMilliseconds } } From 354d602263125f1d6a1f465d5b89ceeae8a06606 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 4 Sep 2026 14:56:55 +0200 Subject: [PATCH 10/14] update unit tests --- .../local/room/migration/MigrationTest.kt | 22 +++ .../data/local/room/dao/SurveySyncStateDao.kt | 4 +- .../room/stores/RoomSurveySyncStateStore.kt | 5 +- .../local/stores/LocalSurveySyncStateStore.kt | 6 +- .../local/LocalLocationOfInterestStoreTest.kt | 13 ++ .../data/remote/FakeRemoteDataStore.kt | 25 +-- .../protobuf/LoiMutationConverterTest.kt | 3 - .../protobuf/ModelToProtoExtKtTest.kt | 9 -- .../schema/LoiCollectionReferenceTest.kt | 83 +++++++++- .../schema/SubmissionMutationConverterTest.kt | 2 - .../LocationOfInterestRepositoryTest.kt | 153 +++++++++++++++++- .../repository/SurveyRepositoryTest.kt | 58 +++++++ .../HomeScreenMapContainerViewModelTest.kt | 5 + .../survey/ActivateSurveyUseCaseTest.kt | 13 ++ .../usecases/survey/SyncSurveyUseCaseTest.kt | 81 ++++++++++ .../FakeLocationOfInterestRepository.kt | 13 +- .../testing/FakeSurveyRepository.kt | 12 ++ 17 files changed, 459 insertions(+), 48 deletions(-) diff --git a/app/src/androidTest/java/org/groundplatform/android/data/local/room/migration/MigrationTest.kt b/app/src/androidTest/java/org/groundplatform/android/data/local/room/migration/MigrationTest.kt index 38df512c88..ed6445eab0 100644 --- a/app/src/androidTest/java/org/groundplatform/android/data/local/room/migration/MigrationTest.kt +++ b/app/src/androidTest/java/org/groundplatform/android/data/local/room/migration/MigrationTest.kt @@ -191,6 +191,28 @@ class MigrationTest { } } + @Test + @Throws(IOException::class) + fun migrate128To129() = runBlocking { + val surveyId = "survey128-129" + + helper.createDatabase(testDatabase, 128).apply { + insert("survey", SQLiteDatabase.CONFLICT_REPLACE, getSurveyContentValues(surveyId)) + close() + } + + // Validates the migrated schema against 129, which adds the survey sync state table. + val migratedDb = helper.runMigrationsAndValidate(testDatabase, 129, true, *migrations) + + // Nothing has been synced yet, so the new table is there and empty. + migratedDb.query("SELECT survey_id FROM survey_sync_state").use { cursor -> + assertEquals("expected no sync state before the first sync", 0, cursor.count) + } + migratedDb.query("SELECT id FROM survey WHERE id = ?", arrayOf(surveyId)).use { cursor -> + assertEquals("expected the seeded survey to survive migration", 1, cursor.count) + } + } + private fun getMigratedRoomDatabase(migrations: Array): LocalDatabase = Room.databaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt b/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt index d92bb91c7d..8914fa19ee 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt @@ -24,6 +24,8 @@ interface SurveySyncStateDao : BaseDao { @Query("SELECT * FROM survey_sync_state WHERE survey_id = :surveyId") suspend fun get(surveyId: String): SurveySyncStateEntity? - @Query("UPDATE survey_sync_state SET latest_loi_server_timestamp = :latestLoiServerTimestamp WHERE survey_id = :surveyId") + @Query( + "UPDATE survey_sync_state SET latest_loi_server_timestamp = :latestLoiServerTimestamp WHERE survey_id = :surveyId" + ) suspend fun updateLatestLoiServerTimestamp(surveyId: String, latestLoiServerTimestamp: Long) } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt index 716833a526..56a1c5dcc1 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt @@ -34,10 +34,7 @@ constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyn return entity?.toModelObject() } - override suspend fun recordIncrementalSync( - surveyId: String, - latestLoiServerTimestamp: Long, - ) { + override suspend fun recordIncrementalSync(surveyId: String, latestLoiServerTimestamp: Long) { surveySyncStateDao.updateLatestLoiServerTimestamp(surveyId, latestLoiServerTimestamp) } diff --git a/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt index 25da4eef0a..430af2ae1d 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt @@ -15,17 +15,13 @@ */ package org.groundplatform.android.data.local.stores -import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveySyncState interface LocalSurveySyncStateStore { suspend fun get(surveyId: String): SurveySyncState? - suspend fun recordIncrementalSync( - surveyId: String, - latestLoiServerTimestamp: Long, - ) + suspend fun recordIncrementalSync(surveyId: String, latestLoiServerTimestamp: Long) suspend fun recordFullSync( surveyId: String, diff --git a/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt b/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt index 711603c747..06c905133d 100644 --- a/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt @@ -329,6 +329,19 @@ class LocalLocationOfInterestStoreTest : BaseHiltTest() { .containsExactly("keep") } + @Test + fun `deleteNotIn keeps LOIs with unsynced mutations`() = runWithTestDispatcher { + localUserStore.insertOrUpdateUser(TEST_USER) + localSurveyStore.insertOrUpdateSurvey(TEST_SURVEY) + // Saved locally and still queued for upload, so no list from the server can mention it. + localLoiStore.applyAndEnqueue(TEST_LOI_MUTATION) + + localLoiStore.deleteNotIn(TEST_SURVEY.id, emptyList()) + + assertThat(localLoiStore.getValidLois(TEST_SURVEY).first().map { it.id }) + .containsExactly(TEST_LOI_MUTATION.locationOfInterestId) + } + @Test fun `deleteNotIn leaves LOIs of other surveys untouched`() = runWithTestDispatcher { localUserStore.insertOrUpdateUser(TEST_USER) diff --git a/app/src/test/java/org/groundplatform/android/data/remote/FakeRemoteDataStore.kt b/app/src/test/java/org/groundplatform/android/data/remote/FakeRemoteDataStore.kt index 7569879dc4..584e0f27b6 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/FakeRemoteDataStore.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/FakeRemoteDataStore.kt @@ -56,6 +56,7 @@ class FakeRemoteDataStore @Inject internal constructor() : RemoteDataStore { val loadUserLoisCall = FakeCall> { userLois } val loadSharedLoisCall = FakeCall> { sharedLois } + var loiCount: (Survey) -> Long = { Long.MAX_VALUE } override fun getRestrictedSurveyList(user: User): Flow> = flowOf(surveys.map { it.toListItem(false) }) @@ -67,8 +68,10 @@ class FakeRemoteDataStore @Inject internal constructor() : RemoteDataStore { override suspend fun loadTermsOfService(): TermsOfService? = termsOfService?.getOrThrow() - override fun loadPredefinedLois(survey: Survey): Flow> = - predefinedLoiPages ?: flowOf(predefinedLois) + override fun loadPredefinedLois( + survey: Survey, + fromTimestamp: Long?, + ): Flow> = predefinedLoiPages ?: flowOf(predefinedLois) override suspend fun applyMutations(mutations: List, user: User) { if (applyMutationError != null) { @@ -88,12 +91,16 @@ class FakeRemoteDataStore @Inject internal constructor() : RemoteDataStore { userProfileRefreshCount++ } - override fun loadUserLois(survey: Survey, ownerUserId: String): Flow> = - flow { - emit(loadUserLoisCall(survey)) - } + override fun loadUserLois( + survey: Survey, + ownerUserId: String, + fromTimestamp: Long?, + ): Flow> = flow { emit(loadUserLoisCall(survey)) } - override fun loadSharedLois(survey: Survey): Flow> = flow { - emit(loadSharedLoisCall(survey)) - } + override fun loadSharedLois( + survey: Survey, + fromTimestamp: Long?, + ): Flow> = flow { emit(loadSharedLoisCall(survey)) } + + override suspend fun countLois(survey: Survey, ownerUserId: String): Long = loiCount(survey) } diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt index 7ceed1a2e6..71c07f5b96 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt @@ -22,7 +22,6 @@ import org.groundplatform.android.FakeData import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_NAME import org.groundplatform.android.proto.AuditInfo.CLIENT_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.DISPLAY_NAME_FIELD_NUMBER -import org.groundplatform.android.proto.AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.USER_ID_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LATITUDE_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LONGITUDE_FIELD_NUMBER @@ -155,7 +154,6 @@ class LoiMutationConverterTest { USER_ID_FIELD_NUMBER.toString() to TEST_USER.id, DISPLAY_NAME_FIELD_NUMBER.toString() to TEST_USER.displayName, CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), - SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), ) ) assertThat(map[CREATED_FIELD_NUMBER.toString()]) @@ -177,7 +175,6 @@ class LoiMutationConverterTest { USER_ID_FIELD_NUMBER.toString() to TEST_USER.id, DISPLAY_NAME_FIELD_NUMBER.toString() to TEST_USER.displayName, CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), - SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), ) ) } diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt index 83846bc333..b7da4a002c 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt @@ -71,7 +71,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -79,7 +78,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" source = LocationOfInterest.Source.FIELD_DATA @@ -123,7 +121,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -131,7 +128,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { @@ -182,7 +178,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -190,7 +185,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { @@ -242,7 +236,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -250,7 +243,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { @@ -302,7 +294,6 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } - serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReferenceTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReferenceTest.kt index 1094bdc807..3e4c9e8d7a 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReferenceTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/LoiCollectionReferenceTest.kt @@ -17,6 +17,9 @@ package org.groundplatform.android.data.remote.firebase.schema import com.google.android.gms.tasks.Tasks import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.AggregateQuery +import com.google.firebase.firestore.AggregateQuerySnapshot +import com.google.firebase.firestore.AggregateSource import com.google.firebase.firestore.CollectionReference import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FieldPath @@ -45,6 +48,7 @@ import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -80,7 +84,8 @@ class LoiCollectionReferenceTest { fun `fetch stops after a page shorter than the page size`() = runTest { pages = mockPages(3) - val emitted = loiCollectionReference.fetchPredefined(SURVEY).toList() + val emitted = + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null).toList() assertThat(emitted.flatten().map { it.id }).containsExactly("loi0", "loi1", "loi2").inOrder() assertThat(pagesFetched).isEqualTo(1) @@ -90,7 +95,8 @@ class LoiCollectionReferenceTest { fun `fetch keeps requesting while pages come back full`() = runTest { pages = mockPages(PAGE_SIZE, PAGE_SIZE, 2) - val emitted = loiCollectionReference.fetchPredefined(SURVEY).toList() + val emitted = + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null).toList() // One emission per page, and the short third page ends it. assertThat(emitted.map { it.size }).containsExactly(PAGE_SIZE, PAGE_SIZE, 2).inOrder() @@ -100,17 +106,19 @@ class LoiCollectionReferenceTest { @Test fun `fetch resumes each page after the last document of the previous one`() = runTest { pages = mockPages(PAGE_SIZE, 1) + val lastOfFirstPage = pages.first().last() - loiCollectionReference.fetchPredefined(SURVEY).toList() + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null).toList() - verify(mockQuery).startAfter("loi${PAGE_SIZE - 1}") + verify(mockQuery).startAfter(lastOfFirstPage) } @Test fun `fetch emits nothing when the collection is empty`() = runTest { pages = mockPages(0) - val emitted = loiCollectionReference.fetchPredefined(SURVEY).toList() + val emitted = + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null).toList() assertThat(emitted).isEmpty() assertThat(pagesFetched).isEqualTo(1) @@ -122,7 +130,8 @@ class LoiCollectionReferenceTest { val brokenFirst = listOf(mockDocument("broken", jobId = "job the survey does not have")) pages = listOf(brokenFirst + fullPage.drop(1), lastPage) - val emitted = loiCollectionReference.fetchPredefined(SURVEY).toList() + val emitted = + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null).toList() assertThat(emitted.first()).hasSize(PAGE_SIZE - 1) assertThat(emitted.flatten().map { it.id }).doesNotContain("broken") @@ -133,22 +142,80 @@ class LoiCollectionReferenceTest { fun `fetch orders by document id and limits each page`() = runTest { pages = mockPages(1) - loiCollectionReference.fetchPredefined(SURVEY).toList() + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null).toList() verify(mockQuery).orderBy(FieldPath.documentId()) verify(mockQuery).limit(PAGE_SIZE.toLong()) } + @Test + fun `fetch from a timestamp asks only for lois modified since then`() = runTest { + pages = mockPages(1) + + loiCollectionReference + .fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = 987_654_321_000) + .toList() + + verify(mockQuery).whereGreaterThanOrEqualTo(LAST_MODIFIED_SERVER_SECONDS, 987_654_321L) + verify(mockQuery).orderBy(LAST_MODIFIED_SERVER_SECONDS) + verify(mockQuery, never()).orderBy(FieldPath.documentId()) + } + + @Test + fun `fetch for one owner asks only for their lois`() = runTest { + pages = mockPages(1) + + loiCollectionReference + .fetch(SURVEY, LoiQueryScope.UserDefined("user-1"), fromTimestamp = null) + .toList() + + verify(mockQuery).whereEqualTo(OWNER_FIELD, "user-1") + } + + @Test + fun `fetch for shared lois asks for every owner's`() = runTest { + pages = mockPages(1) + + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Shared, fromTimestamp = null).toList() + + verify(mockQuery, never()).whereEqualTo(eq(OWNER_FIELD), any()) + } + + @Test + fun `count for one owner counts only their lois`() = runTest { + mockAggregateCount(42L) + + assertThat(loiCollectionReference.count(LoiQueryScope.UserDefined("user-1"))).isEqualTo(42L) + verify(mockQuery).whereEqualTo(OWNER_FIELD, "user-1") + } + + @Test + fun `count returns the aggregated document count without fetching them`() = runTest { + mockAggregateCount(42L) + + assertThat(loiCollectionReference.count(LoiQueryScope.Predefined)).isEqualTo(42L) + assertThat(pagesFetched).isEqualTo(0) + } + @Test fun `fetch is lazy until collected`() = runTest { pages = mockPages(1) - loiCollectionReference.fetchPredefined(SURVEY) + loiCollectionReference.fetch(SURVEY, LoiQueryScope.Predefined, fromTimestamp = null) verify(mockQuery, never()).get() assertThat(pagesFetched).isEqualTo(0) } + private fun mockAggregateCount(count: Long) { + val aggregateSnapshot = mock { on { this.count } doReturn count } + val aggregateQuery = + mock { + on { get(AggregateSource.SERVER) } doReturn Tasks.forResult(aggregateSnapshot) + } + whenever(mockQuery.count()).thenReturn(aggregateQuery) + } + private fun mockPages(vararg sizes: Int): List> { var next = 0 return sizes.map { size -> diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt index c3dce4dcf9..9cd62400bf 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt @@ -23,7 +23,6 @@ import org.groundplatform.android.data.remote.firebase.protobuf.createSubmission import org.groundplatform.android.data.remote.firebase.protobuf.toFirestoreMap import org.groundplatform.android.proto.AuditInfo.CLIENT_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.DISPLAY_NAME_FIELD_NUMBER -import org.groundplatform.android.proto.AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.USER_ID_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LATITUDE_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LONGITUDE_FIELD_NUMBER @@ -363,7 +362,6 @@ class SubmissionMutationConverterTest { USER_ID_FIELD_NUMBER.toString() to user.id, DISPLAY_NAME_FIELD_NUMBER.toString() to user.displayName, CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), - SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), ) @Test diff --git a/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt index 5e8c04239f..4fe08b6936 100644 --- a/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt @@ -33,12 +33,15 @@ import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.data.sync.MutationSyncWorkManager import org.groundplatform.android.system.auth.FakeAuthenticationManager import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.geometry.LinearRing import org.groundplatform.domain.model.geometry.Point import org.groundplatform.domain.model.geometry.Polygon import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.mutation.Mutation.Type.CREATE +import org.groundplatform.domain.model.mutation.Mutation.Type.DELETE +import org.groundplatform.domain.model.mutation.Mutation.Type.UPDATE import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.MutationRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface @@ -153,7 +156,7 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { listOf(TEST_AREA_OF_INTEREST_1, TEST_AREA_OF_INTEREST_2), ) - locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) .containsExactlyElementsIn(TEST_LOCATIONS_OF_INTEREST) @@ -172,7 +175,7 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { savedWhenPageRequested += localLoiStore.getLoiCount(TEST_SURVEY.id) } - locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) assertThat(savedWhenPageRequested).containsExactly(0, 2, 3).inOrder() } @@ -186,7 +189,7 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { } assertFailsWith { - locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) } val lois = locationOfInterestRepository.getValidLois(TEST_SURVEY).first() @@ -203,7 +206,7 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { // Sync again, with the server now returning two of them across separate pages. fakeRemoteDataStore.predefinedLoiPages = flowOf(listOf(TEST_POINT_OF_INTEREST_1), listOf(TEST_AREA_OF_INTEREST_2)) - locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) .containsExactly(TEST_POINT_OF_INTEREST_1, TEST_AREA_OF_INTEREST_2) @@ -216,7 +219,7 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { if (it.id == updated.id) updated else it } - locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) val lois = locationOfInterestRepository.getValidLois(TEST_SURVEY).first() assertThat(lois).contains(updated) @@ -231,12 +234,149 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { locationOfInterestRepository.applyAndEnqueue(pending.toMutation(CREATE, TEST_USER.id)) fakeRemoteDataStore.predefinedLois = listOf(TEST_POINT_OF_INTEREST_1) - locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) assertThat(locationOfInterestRepository.getOfflineLoi(TEST_SURVEY.id, pending.id)) .isEqualTo(pending) } + @Test + fun `Incremental sync keeps the lois which were already stored intact`() = runWithTestDispatcher { + val newLoi = createPoint("6", COORDINATE_2) + fakeRemoteDataStore.predefinedLois = listOf(newLoi) + + locationOfInterestRepository.syncLocationsOfInterest( + TEST_SURVEY, + SurveySyncMode.Incremental(SERVER_TIMESTAMP), + ) + + // LOIs missing from an incremental response are left alone, not deleted. + assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) + .containsExactlyElementsIn(TEST_LOCATIONS_OF_INTEREST + newLoi) + } + + @Test + fun `sync reports the newest server timestamp it has seen`() = runWithTestDispatcher { + val loi = createPoint("6", COORDINATE_2) + fakeRemoteDataStore.predefinedLois = + listOf(loi.copy(lastModified = loi.lastModified.copy(serverTimestamp = SERVER_TIMESTAMP))) + + val result = + locationOfInterestRepository.syncLocationsOfInterest( + TEST_SURVEY, + SurveySyncMode.Incremental(0), + ) + + assertThat(result.latestLoiServerTimestamp).isEqualTo(SERVER_TIMESTAMP) + } + + @Test + fun `sync re-reads everything when the remote loi count has fallen behind`() = + runWithTestDispatcher { + val remaining = TEST_LOCATIONS_OF_INTEREST - TEST_POINT_OF_INTEREST_1 + fakeRemoteDataStore.predefinedLois = remaining + fakeRemoteDataStore.loiCount = { remaining.size.toLong() } + + val result = + locationOfInterestRepository.syncLocationsOfInterest( + TEST_SURVEY, + SurveySyncMode.Incremental(0), + ) + + assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) + .containsExactlyElementsIn(remaining) + // The caller asked for an incremental read and has to hear that it got a full one. + assertThat(result.mode).isEqualTo(SurveySyncMode.Full) + } + + @Test + fun `sync keeps local lois when the counts agree`() = runWithTestDispatcher { + // Nothing changed remotely, so the incremental fetch comes back empty. + fakeRemoteDataStore.predefinedLois = emptyList() + fakeRemoteDataStore.loiCount = { TEST_LOCATIONS_OF_INTEREST.size.toLong() } + + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Incremental(0)) + + assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) + .containsExactlyElementsIn(TEST_LOCATIONS_OF_INTEREST) + } + + @Test + fun `sync ignores a local count inflated by lois still waiting to upload`() = + runWithTestDispatcher { + // Created locally and not yet uploaded, so the server cannot know about it. + val pending = + LOCATION_OF_INTEREST.copy(customId = "", lastModified = LOCATION_OF_INTEREST.created) + locationOfInterestRepository.applyAndEnqueue(pending.toMutation(CREATE, TEST_USER.id)) + fakeRemoteDataStore.predefinedLois = emptyList() + fakeRemoteDataStore.loiCount = { TEST_LOCATIONS_OF_INTEREST.size.toLong() } + + locationOfInterestRepository.syncLocationsOfInterest( + TEST_SURVEY, + SurveySyncMode.Incremental(0), + ) + + assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) + .containsAtLeastElementsIn(TEST_LOCATIONS_OF_INTEREST) + } + + @Test + fun `sync ignores a count gap left by an loi held back by a pending mutation`() = + runWithTestDispatcher { + locationOfInterestRepository.applyAndEnqueue( + TEST_POINT_OF_INTEREST_1.toMutation(UPDATE, TEST_USER.id) + ) + fakeRemoteDataStore.predefinedLois = emptyList() + fakeRemoteDataStore.loiCount = { (TEST_LOCATIONS_OF_INTEREST.size - 1).toLong() } + + val result = + locationOfInterestRepository.syncLocationsOfInterest( + TEST_SURVEY, + SurveySyncMode.Incremental(0), + ) + + // The read stayed incremental, so no re-read of everything was triggered. + assertThat(result.mode).isEqualTo(SurveySyncMode.Incremental(0)) + } + + @Test + fun `sync re-reads everything when a deletion is missed while a delete waits to upload`() = + runWithTestDispatcher { + // Deleted locally, so it already left the local count and can't hide the missed deletion. + locationOfInterestRepository.applyAndEnqueue( + TEST_POINT_OF_INTEREST_2.toMutation(DELETE, TEST_USER.id) + ) + val remaining = + TEST_LOCATIONS_OF_INTEREST - TEST_POINT_OF_INTEREST_1 - TEST_POINT_OF_INTEREST_2 + fakeRemoteDataStore.predefinedLois = remaining + fakeRemoteDataStore.loiCount = { remaining.size.toLong() } + + locationOfInterestRepository.syncLocationsOfInterest( + TEST_SURVEY, + SurveySyncMode.Incremental(0), + ) + + assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) + .containsExactlyElementsIn(remaining) + } + + @Test + fun `sync does not count remote lois during a full sync`() = runWithTestDispatcher { + var counted = 0 + fakeRemoteDataStore.loiCount = { + counted++ + TEST_LOCATIONS_OF_INTEREST.size.toLong() + } + + // A full sync already drops whatever the server stopped returning, so a count adds nothing. + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) + assertThat(counted).isEqualTo(0) + + // An incremental one leaves deletions behind, so it has to look. + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Incremental(0)) + assertThat(counted).isEqualTo(1) + } + @Test fun `loi within bounds when out of bounds returns empty list`() = runWithTestDispatcher { val southwest = Coordinates(-60.0, -60.0) @@ -377,6 +517,7 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { private val COORDINATE_1 = Coordinates(-20.0, -20.0) private val COORDINATE_2 = Coordinates(0.0, 0.0) private val COORDINATE_3 = Coordinates(20.0, 20.0) + private const val SERVER_TIMESTAMP = 1_700_000_000_000 private val AREA_OF_INTEREST = FakeData.AREA_OF_INTEREST private val LOCATION_OF_INTEREST = FakeData.LOCATION_OF_INTEREST diff --git a/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt index b95b089a4c..22419db1de 100644 --- a/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt @@ -25,6 +25,9 @@ import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.SURVEY import org.groundplatform.android.data.local.stores.LocalSurveyStore import org.groundplatform.android.data.remote.FakeRemoteDataStore +import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode +import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.usecases.survey.ActivateSurveyUseCase import org.junit.Before @@ -47,6 +50,57 @@ class SurveyRepositoryTest : BaseHiltTest() { fakeRemoteDataStore.surveys = listOf(SURVEY) } + @Test + fun `getSyncState returns null for a survey which has never been synced`() = + runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + + assertThat(surveyRepository.getSyncState(SURVEY.id)).isNull() + } + + @Test + fun `recordSyncState stores the timestamp and the visibility after a full read`() = + runWithTestDispatcher { + val survey = SURVEY.copy(dataVisibility = Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) + localSurveyStore.insertOrUpdateSurvey(survey) + + surveyRepository.recordSyncState( + survey, + SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP), + ) + + val state = checkNotNull(surveyRepository.getSyncState(survey.id)) + assertThat(state.latestLoiServerTimestamp).isEqualTo(TEST_LATEST_LOI_TIMESTAMP) + assertThat(state.syncedDataVisibility).isEqualTo(survey.dataVisibility) + assertThat(state.lastFullSyncClientTimestamp).isGreaterThan(0) + } + + @Test + fun `recordSyncState updates only the timestamp after an incremental read`() = + runWithTestDispatcher { + val survey = SURVEY.copy(dataVisibility = Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) + localSurveyStore.insertOrUpdateSurvey(survey) + surveyRepository.recordSyncState( + survey, + SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP), + ) + val afterFullRead = checkNotNull(surveyRepository.getSyncState(survey.id)) + + surveyRepository.recordSyncState( + survey, + SyncResult( + SurveySyncMode.Incremental(TEST_LATEST_LOI_TIMESTAMP), + TEST_LATEST_LOI_TIMESTAMP + 1, + ), + ) + + val state = checkNotNull(surveyRepository.getSyncState(survey.id)) + assertThat(state.latestLoiServerTimestamp).isEqualTo(TEST_LATEST_LOI_TIMESTAMP + 1) + assertThat(state.lastFullSyncClientTimestamp) + .isEqualTo(afterFullRead.lastFullSyncClientTimestamp) + assertThat(state.syncedDataVisibility).isEqualTo(afterFullRead.syncedDataVisibility) + } + @Test fun `setting selectedSurveyId updates the active survey`() = runWithTestDispatcher { localSurveyStore.insertOrUpdateSurvey(SURVEY) @@ -95,4 +149,8 @@ class SurveyRepositoryTest : BaseHiltTest() { surveyRepository.getRemoteSurvey(SURVEY.id) } } + + companion object { + private const val TEST_LATEST_LOI_TIMESTAMP = 987654321L + } } diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index e7966e1626..760131ea91 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -42,10 +42,12 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface +import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.usecases.survey.ActivateSurveyUseCase import org.groundplatform.domain.util.Constants.CLUSTERING_ZOOM_THRESHOLD @@ -83,6 +85,9 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { // Setup survey and LOIs remoteDataStore.surveys = listOf(SURVEY) remoteDataStore.predefinedLois = listOf(LOCATION_OF_INTEREST) + // Activating a survey syncs its LOIs, and the sync reports back where it left off. + whenever(loiRepository.syncLocationsOfInterest(any(), any())) + .thenReturn(SyncResult(SurveySyncMode.Full, latestLoiServerTimestamp = 0)) activateSurvey(SURVEY.id) advanceUntilIdle() whenever(loiRepository.getWithinBounds(SURVEY, BOUNDS)) diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ActivateSurveyUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ActivateSurveyUseCaseTest.kt index e55a6094c6..5944333ac4 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ActivateSurveyUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ActivateSurveyUseCaseTest.kt @@ -22,6 +22,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.testing.FakeLocationOfInterestRepository import org.groundplatform.testing.FakeSurveyRepository @@ -46,6 +47,18 @@ class ActivateSurveyUseCaseTest { assertEquals(survey, surveyRepository.getOfflineSurvey(survey.id)) } + @Test + fun `Do a full sync on a survey which isn't available offline yet`() = runTest { + val survey = FakeDataGenerator.newSurvey(id = "survey-1") + surveyRepository.remoteSurveys = listOf(survey) + + activateSurvey(survey.id) + + // Nothing of the survey is stored yet, so there is no cursor to resume from: removing a survey + // takes its sync state along with it. + assertEquals(SurveySyncMode.Full, loiRepository.lastSyncMode) + } + @Test fun `Throws error when survey can't be made available offline`() = runTest { surveyRepository.onGetRemoteSurveyCall.overrideBehavior { error("Remote failed") } diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt index cdfd58a5a8..05aa204d20 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt @@ -20,7 +20,13 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days import kotlinx.coroutines.test.runTest +import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode +import org.groundplatform.domain.model.SurveySyncState +import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.testing.FakeLocationOfInterestRepository import org.groundplatform.testing.FakeSurveyRepository @@ -57,4 +63,79 @@ class SyncSurveyUseCaseTest { assertFailsWith { syncSurvey(FakeDataGenerator.newSurvey().id) } } + + @Test + fun `reads every LOI when the survey has never been synced`() = runTest { + assertEquals(SurveySyncMode.Full, executeSync(syncState = null)) + } + + @Test + fun `reads every LOI when the last sync covered a different survey data visibility setting`() = + runTest { + val state = + SurveySyncState( + surveyId = FakeDataGenerator.newSurvey().id, + latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP, + lastFullSyncClientTimestamp = Clock.System.now().toEpochMilliseconds(), + syncedDataVisibility = Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS, + ) + + assertEquals(SurveySyncMode.Full, executeSync(state)) + } + + @Test + fun `reads every LOI when the last full sync fell out of the message backlog`() = runTest { + val state = + SurveySyncState( + surveyId = FakeDataGenerator.newSurvey().id, + latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP, + lastFullSyncClientTimestamp = + Clock.System.now().toEpochMilliseconds() - 29.days.inWholeMilliseconds, + syncedDataVisibility = null, + ) + + assertEquals(SurveySyncMode.Full, executeSync(state)) + } + + @Test + fun `resumes from the last cursor while the backlog still reaches it`() = runTest { + val state = + SurveySyncState( + surveyId = FakeDataGenerator.newSurvey().id, + latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP, + lastFullSyncClientTimestamp = + Clock.System.now().toEpochMilliseconds() - 27.days.inWholeMilliseconds, + syncedDataVisibility = null, + ) + + assertEquals(SurveySyncMode.Incremental(TEST_LATEST_LOI_TIMESTAMP), executeSync(state)) + } + + @Test + fun `records where the sync of the LOIs left off`() = runTest { + val survey = FakeDataGenerator.newSurvey() + surveyRepository.remoteSurveys = listOf(survey) + loiRepository.syncResult = SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP) + + syncSurvey(survey.id) + + assertEquals( + SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP), + surveyRepository.lastRecordedSyncState, + ) + } + + private suspend fun executeSync(syncState: SurveySyncState?): SurveySyncMode? { + val survey = FakeDataGenerator.newSurvey() + surveyRepository.remoteSurveys = listOf(survey) + surveyRepository.syncState = syncState + + syncSurvey(survey.id) + + return loiRepository.lastSyncMode + } + + companion object { + private const val TEST_LATEST_LOI_TIMESTAMP = 987654321L + } } diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt index 8d0848efe5..bd875b6849 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt @@ -18,6 +18,7 @@ package org.groundplatform.testing import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.geometry.Geometry import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest @@ -29,10 +30,20 @@ class FakeLocationOfInterestRepository : LocationOfInterestRepositoryInterface { var offlineLoi = FakeDataGenerator.newLocationOfInterest() var hasValidLois = true + var syncResult: LocationOfInterestRepositoryInterface.SyncResult? = null + val syncLocationsOfInterestCall = FakeCall {} - override suspend fun syncLocationsOfInterest(survey: Survey) { + var lastSyncMode: SurveySyncMode? = null + + override suspend fun syncLocationsOfInterest( + survey: Survey, + mode: SurveySyncMode, + ): LocationOfInterestRepositoryInterface.SyncResult { + lastSyncMode = mode syncLocationsOfInterestCall(survey) + return syncResult + ?: LocationOfInterestRepositoryInterface.SyncResult(mode, latestLoiServerTimestamp = 0) } override suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest = diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt index 4ddd59a085..fda4fda6be 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt @@ -21,7 +21,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User +import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.domain.repository.SurveyRepositoryInterface class FakeSurveyRepository : SurveyRepositoryInterface { @@ -41,6 +43,10 @@ class FakeSurveyRepository : SurveyRepositoryInterface { var remoteSurveys: List = emptyList() + var syncState: SurveySyncState? = null + + var lastRecordedSyncState: SyncResult? = null + val remoteListItemsFlow = MutableStateFlow>(emptyList()) var remoteListItems: List get() = remoteListItemsFlow.value @@ -59,6 +65,12 @@ class FakeSurveyRepository : SurveyRepositoryInterface { offlineSurveys = offlineSurveys.filterNot { it.id == survey.id } + survey } + override suspend fun getSyncState(surveyId: String): SurveySyncState? = syncState + + override suspend fun recordSyncState(survey: Survey, loiSyncResult: SyncResult) { + lastRecordedSyncState = loiSyncResult + } + override suspend fun getRemoteSurvey(surveyId: String): Survey? = onGetRemoteSurveyCall(surveyId) override fun getRemoteSurveys(user: User): Flow> = remoteListItemsFlow From 3e7780aac27412504fc7b70994cb811f876cd93c Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 8 Sep 2026 20:36:49 +0200 Subject: [PATCH 11/14] remove unnecessary changes --- .../data/remote/firebase/FirebaseMessagingService.kt | 1 - .../data/remote/firebase/protobuf/ModelToProtoExt.kt | 1 + .../android/data/remote/firebase/schema/LoiConverter.kt | 2 +- .../android/data/sync/SurveySyncService.kt | 7 ++----- .../groundplatform/android/data/sync/SurveySyncWorker.kt | 2 +- .../remote/firebase/protobuf/LoiMutationConverterTest.kt | 3 +++ .../remote/firebase/protobuf/ModelToProtoExtKtTest.kt | 9 +++++++++ .../firebase/schema/SubmissionMutationConverterTest.kt | 2 ++ .../usecases/survey/MakeSurveyAvailableOfflineUseCase.kt | 2 +- 9 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt index af9708d2c1..75cf2c1c3b 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirebaseMessagingService.kt @@ -45,7 +45,6 @@ class FirebaseMessagingService : FirebaseMessagingService() { return } Timber.v("Message received from topic ${remoteMessage.from}") - surveySyncService.enqueueSync(surveyId) } diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt index 8ba19c3672..dcc6d18416 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExt.kt @@ -156,6 +156,7 @@ private fun createAuditInfoMessage(user: User, timestamp: Long) = auditInfo { emailAddress = user.email photoUrl = user.photoUrl ?: photoUrl clientTimestamp = timestamp.toMessage() + serverTimestamp = timestamp.toMessage() } private fun Long.toMessage() = timestamp { seconds = this@toMessage / 1000 } diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt index 0ccb18e6a3..f42c2131d9 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/schema/LoiConverter.kt @@ -54,7 +54,7 @@ object LoiConverter { // Degrade gracefully when audit info missing in remote db. val created = AuditInfoConverter.toAuditInfo(loiProto.created) val lastModified = - if (loiProto.hasLastModified()) { + if (loiProto.lastModified != null) { AuditInfoConverter.toAuditInfo(loiProto.lastModified) } else { created diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt index a5b9244156..e48c62f986 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncService.kt @@ -15,7 +15,6 @@ */ package org.groundplatform.android.data.sync -import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager import java.util.UUID @@ -31,10 +30,8 @@ class SurveySyncService @Inject constructor(private val workManager: WorkManager * * @return The id of the worker request, used in tests to retrieve the worker status. */ - fun enqueueSync(surveyId: String): UUID = - enqueue(surveyId, SurveySyncWorker.createInputData(surveyId)) - - private fun enqueue(surveyId: String, inputData: Data): UUID { + fun enqueueSync(surveyId: String): UUID { + val inputData = SurveySyncWorker.createInputData(surveyId) val request = WorkRequestBuilder() .setWorkerClass(SurveySyncWorker::class.java) diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt index 1bde87faa0..9b4370c7d0 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt @@ -114,7 +114,7 @@ constructor( */ internal const val SYNC_TIMEOUT_MILLIS = 8 * 60 * 1000L - /** Returns a new work [Data] object requesting a sync of the specified survey. */ + /** Returns a new work [Data] object containing the specified survey id. */ fun createInputData(surveyId: String): Data = Data.Builder().putString(SURVEY_ID_PARAM_KEY, surveyId).build() } diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt index 71c07f5b96..7ceed1a2e6 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/LoiMutationConverterTest.kt @@ -22,6 +22,7 @@ import org.groundplatform.android.FakeData import org.groundplatform.android.FakeData.LOCATION_OF_INTEREST_NAME import org.groundplatform.android.proto.AuditInfo.CLIENT_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.DISPLAY_NAME_FIELD_NUMBER +import org.groundplatform.android.proto.AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.USER_ID_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LATITUDE_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LONGITUDE_FIELD_NUMBER @@ -154,6 +155,7 @@ class LoiMutationConverterTest { USER_ID_FIELD_NUMBER.toString() to TEST_USER.id, DISPLAY_NAME_FIELD_NUMBER.toString() to TEST_USER.displayName, CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), + SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), ) ) assertThat(map[CREATED_FIELD_NUMBER.toString()]) @@ -175,6 +177,7 @@ class LoiMutationConverterTest { USER_ID_FIELD_NUMBER.toString() to TEST_USER.id, DISPLAY_NAME_FIELD_NUMBER.toString() to TEST_USER.displayName, CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), + SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), ) ) } diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt index b7da4a002c..83846bc333 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/protobuf/ModelToProtoExtKtTest.kt @@ -71,6 +71,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -78,6 +79,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" source = LocationOfInterest.Source.FIELD_DATA @@ -121,6 +123,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -128,6 +131,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { @@ -178,6 +182,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -185,6 +190,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { @@ -236,6 +242,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } lastModified = auditInfo { userId = "userId" @@ -243,6 +250,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { @@ -294,6 +302,7 @@ class ModelToProtoExtKtTest { emailAddress = user.email photoUrl = "" clientTimestamp = timestamp { seconds = 987654321L } + serverTimestamp = timestamp { seconds = 987654321L } } customTag = "customId" geometry = geometry { diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt index 9cd62400bf..c3dce4dcf9 100644 --- a/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/schema/SubmissionMutationConverterTest.kt @@ -23,6 +23,7 @@ import org.groundplatform.android.data.remote.firebase.protobuf.createSubmission import org.groundplatform.android.data.remote.firebase.protobuf.toFirestoreMap import org.groundplatform.android.proto.AuditInfo.CLIENT_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.DISPLAY_NAME_FIELD_NUMBER +import org.groundplatform.android.proto.AuditInfo.SERVER_TIMESTAMP_FIELD_NUMBER import org.groundplatform.android.proto.AuditInfo.USER_ID_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LATITUDE_FIELD_NUMBER import org.groundplatform.android.proto.Coordinates.LONGITUDE_FIELD_NUMBER @@ -362,6 +363,7 @@ class SubmissionMutationConverterTest { USER_ID_FIELD_NUMBER.toString() to user.id, DISPLAY_NAME_FIELD_NUMBER.toString() to user.displayName, CLIENT_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), + SERVER_TIMESTAMP_FIELD_NUMBER.toString() to mapOf("1" to 987654321L), ) @Test diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt index baeeef7c9f..4de83a2934 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/MakeSurveyAvailableOfflineUseCase.kt @@ -31,5 +31,5 @@ class MakeSurveyAvailableOfflineUseCase( private val syncSurvey: SyncSurveyUseCase, ) { suspend operator fun invoke(surveyId: String): Survey? = - syncSurvey(surveyId = surveyId)?.also { surveyRepository.subscribeToSurveyUpdates(surveyId) } + syncSurvey(surveyId)?.also { surveyRepository.subscribeToSurveyUpdates(surveyId) } } From 2102830b08a06f0acd010b8a4ae6c909c46e1ebf Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 8 Sep 2026 21:31:58 +0200 Subject: [PATCH 12/14] update FULL_SYNC_INTERVAL_MILLIS --- .../android/repository/LocationOfInterestRepository.kt | 8 ++++---- .../domain/usecases/survey/SyncSurveyUseCase.kt | 4 ++-- .../domain/usecases/survey/SyncSurveyUseCaseTest.kt | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index 7f87a2f0c6..70b6a3c551 100644 --- a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt @@ -67,12 +67,12 @@ constructor( val missedDeletion = mode is SurveySyncMode.Incremental && hasMissedDeletion(survey, ownerUserId) - if (missedDeletion) { - Timber.d("Reading all of survey ${survey.id}, local LOIs outnumber the remote ones") - } - val effectiveMode = if (missedDeletion) SurveySyncMode.Full else mode + Timber.d( + "Syncing LOIs of survey ${survey.id}: $effectiveMode${if (missedDeletion) ", escalated from $mode since local LOIs outnumber remote" else ""}" + ) + return LocationOfInterestRepositoryInterface.SyncResult( mode = effectiveMode, latestLoiServerTimestamp = syncLois(survey, ownerUserId, effectiveMode), diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt index 006626738c..8c35e653d6 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt @@ -63,7 +63,7 @@ class SyncSurveyUseCase( } private companion object { - // An undelivered FCM is stored for a max of 28 days - val FULL_SYNC_INTERVAL_MILLIS = 28.days.inWholeMilliseconds + // Periodic full survey reads prevent local incremental syncs from drifting from the server. + val FULL_SYNC_INTERVAL_MILLIS = 7.days.inWholeMilliseconds } } diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt index 05aa204d20..47f9f8f1b6 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt @@ -21,7 +21,6 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock -import kotlin.time.Duration.Companion.days import kotlinx.coroutines.test.runTest import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveySyncMode @@ -90,7 +89,8 @@ class SyncSurveyUseCaseTest { surveyId = FakeDataGenerator.newSurvey().id, latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP, lastFullSyncClientTimestamp = - Clock.System.now().toEpochMilliseconds() - 29.days.inWholeMilliseconds, + Clock.System.now().toEpochMilliseconds() - + SyncSurveyUseCase.FULL_SYNC_INTERVAL_MILLIS * 2, syncedDataVisibility = null, ) @@ -104,7 +104,8 @@ class SyncSurveyUseCaseTest { surveyId = FakeDataGenerator.newSurvey().id, latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP, lastFullSyncClientTimestamp = - Clock.System.now().toEpochMilliseconds() - 27.days.inWholeMilliseconds, + Clock.System.now().toEpochMilliseconds() - + SyncSurveyUseCase.FULL_SYNC_INTERVAL_MILLIS / 2, syncedDataVisibility = null, ) From 0a41f53d254d3a9566e9aaed342aab78250089fb Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 8 Sep 2026 22:24:27 +0200 Subject: [PATCH 13/14] move full decision of sync mode to the usecase and make the logic to check for pending mutations more robust --- .../room/dao/LocationOfInterestMutationDao.kt | 14 +++++ .../stores/RoomLocationOfInterestStore.kt | 11 +++- .../room/stores/RoomSurveySyncStateStore.kt | 19 ++++--- .../stores/LocalLocationOfInterestStore.kt | 8 ++- .../LocationOfInterestRepository.kt | 50 ++++------------- .../android/repository/SurveyRepository.kt | 13 ++--- .../LocationOfInterestRepositoryTest.kt | 54 +++++-------------- .../repository/SurveyRepositoryTest.kt | 17 ++---- .../HomeScreenMapContainerViewModelTest.kt | 5 +- .../LocationOfInterestRepositoryInterface.kt | 9 ++-- .../repository/SurveyRepositoryInterface.kt | 8 ++- .../usecases/survey/SyncSurveyUseCase.kt | 8 +-- .../usecases/survey/SyncSurveyUseCaseTest.kt | 30 ++++++++--- .../FakeLocationOfInterestRepository.kt | 15 +++--- .../testing/FakeSurveyRepository.kt | 15 ++++-- 15 files changed, 129 insertions(+), 147 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt b/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt index 577cd08564..59a78b9964 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt @@ -20,6 +20,7 @@ import androidx.room.Query import kotlinx.coroutines.flow.Flow import org.groundplatform.android.data.local.room.entity.LocationOfInterestMutationEntity import org.groundplatform.android.data.local.room.fields.MutationEntitySyncStatus +import org.groundplatform.android.data.local.room.fields.MutationEntityType /** * Provides low-level read/write operations of [LocationOfInterestMutationEntity] to/from the local @@ -50,4 +51,17 @@ interface LocationOfInterestMutationDao : BaseDao + + /** Returns how many of the survey's LOIs hold a mutation of another type in one of the states. */ + @Query( + "SELECT COUNT(DISTINCT location_of_interest_id) FROM location_of_interest_mutation " + + "WHERE survey_id = :surveyId " + + "AND type != :excludedType " + + "AND state IN (:allowedStates)" + ) + suspend fun countLocationOfInterestIds( + surveyId: String, + excludedType: MutationEntityType, + vararg allowedStates: MutationEntitySyncStatus, + ): Int } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt index 41ebe76abe..5bf81f5708 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomLocationOfInterestStore.kt @@ -32,6 +32,7 @@ import org.groundplatform.android.data.local.room.entity.LocationOfInterestEntit import org.groundplatform.android.data.local.room.entity.LocationOfInterestMutationEntity import org.groundplatform.android.data.local.room.fields.EntityDeletionState import org.groundplatform.android.data.local.room.fields.MutationEntitySyncStatus +import org.groundplatform.android.data.local.room.fields.MutationEntityType import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore import org.groundplatform.android.util.Debug.logOnFailure import org.groundplatform.domain.model.Survey @@ -143,7 +144,15 @@ class RoomLocationOfInterestStore @Inject internal constructor() : LocalLocation locationOfInterestDao.upsertAll(entities) } - override suspend fun deleteNotIn(surveyId: String, ids: List) { + override suspend fun countPendingNonDeletedLois(surveyId: String): Int = + locationOfInterestMutationDao.countLocationOfInterestIds( + surveyId, + MutationEntityType.DELETE, + MutationEntitySyncStatus.PENDING, + MutationEntitySyncStatus.IN_PROGRESS, + ) + + override suspend fun deleteNotIn(surveyId: String, ids: Collection) { val idsToKeep = ids.toSet() localDatabase.withTransaction { // NOTE(#2652): Never delete an LOI with unsynced changes, including one saved while the diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt index 56a1c5dcc1..f51fcd6f00 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt @@ -17,12 +17,11 @@ package org.groundplatform.android.data.local.room.stores import javax.inject.Inject import kotlin.time.Clock +import org.groundplatform.android.data.local.room.converter.toLocalDataStoreObject import org.groundplatform.android.data.local.room.converter.toModelObject import org.groundplatform.android.data.local.room.dao.SurveySyncStateDao import org.groundplatform.android.data.local.room.dao.insertOrUpdate -import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore -import org.groundplatform.android.data.remote.firebase.protobuf.toProto import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveySyncState @@ -30,8 +29,7 @@ class RoomSurveySyncStateStore @Inject constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyncStateStore { override suspend fun get(surveyId: String): SurveySyncState? { - val entity = surveySyncStateDao.get(surveyId) - return entity?.toModelObject() + return surveySyncStateDao.get(surveyId)?.toModelObject() } override suspend fun recordIncrementalSync(surveyId: String, latestLoiServerTimestamp: Long) { @@ -44,12 +42,13 @@ constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyn dataVisibility: Survey.DataVisibility?, ) { surveySyncStateDao.insertOrUpdate( - SurveySyncStateEntity( - surveyId = surveyId, - latestLoiServerTimestamp = latestLoiServerTimestamp, - lastFullSyncClientTimestamp = Clock.System.now().toEpochMilliseconds(), - syncedDataVisibility = dataVisibility?.toProto()?.ordinal, - ) + SurveySyncState( + surveyId = surveyId, + latestLoiServerTimestamp = latestLoiServerTimestamp, + lastFullSyncClientTimestamp = Clock.System.now().toEpochMilliseconds(), + syncedDataVisibility = dataVisibility, + ) + .toLocalDataStoreObject() ) } } diff --git a/app/src/main/java/org/groundplatform/android/data/local/stores/LocalLocationOfInterestStore.kt b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalLocationOfInterestStore.kt index 1ae85fae0d..304c26eab3 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/stores/LocalLocationOfInterestStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalLocationOfInterestStore.kt @@ -65,5 +65,11 @@ interface LocalLocationOfInterestStore : /** Inserts or updates all the given LOIs in a single transaction. */ suspend fun insertOrUpdateAll(lois: List) - suspend fun deleteNotIn(surveyId: String, ids: List) + suspend fun deleteNotIn(surveyId: String, ids: Collection) + + /** + * Returns the number of survey LOIs with a pending local change that has not yet been synced, + * excluding deletes. + */ + suspend fun countPendingNonDeletedLois(surveyId: String): Int } diff --git a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index 70b6a3c551..bd59906a90 100644 --- a/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt @@ -19,7 +19,6 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore import org.groundplatform.android.data.local.stores.LocalSurveyStore @@ -59,24 +58,9 @@ constructor( private val uuidGenerator: OfflineUuidGenerator, private val authenticationManager: AuthenticationManager, ) : LocationOfInterestRepositoryInterface { - override suspend fun syncLocationsOfInterest( - survey: Survey, - mode: SurveySyncMode, - ): LocationOfInterestRepositoryInterface.SyncResult { - val ownerUserId = authenticationManager.getAuthenticatedUser().id - - val missedDeletion = - mode is SurveySyncMode.Incremental && hasMissedDeletion(survey, ownerUserId) - val effectiveMode = if (missedDeletion) SurveySyncMode.Full else mode - - Timber.d( - "Syncing LOIs of survey ${survey.id}: $effectiveMode${if (missedDeletion) ", escalated from $mode since local LOIs outnumber remote" else ""}" - ) - - return LocationOfInterestRepositoryInterface.SyncResult( - mode = effectiveMode, - latestLoiServerTimestamp = syncLois(survey, ownerUserId, effectiveMode), - ) + override suspend fun syncLocationsOfInterest(survey: Survey, mode: SurveySyncMode): Long { + Timber.d("Syncing LOIs of survey ${survey.id}: $mode") + return syncLois(survey, authenticationManager.getAuthenticatedUser().id, mode) } /** Reads the survey's LOIs into the local db, returning the newest server timestamp it saw. */ @@ -105,31 +89,19 @@ constructor( } if (mode is SurveySyncMode.Full) { - // NOTE(#2652): Delete LOIs in local db not returned in latest list from server. The store - // keeps the ones with unsynced local changes, since dropping one would lose the mutation - // before it reaches the server. - localLoiStore.deleteNotIn(survey.id, syncedLoiIds.toList()) + localLoiStore.deleteNotIn(survey.id, syncedLoiIds) } return newestLoiTimestamp } - /** Returns whether the local db holds an LOI which a full sync would find gone from remote. */ - private suspend fun hasMissedDeletion(survey: Survey, ownerUserId: String): Boolean { - val mutations = localLoiStore.getAllSurveyMutations(survey).firstOrNull().orEmpty() - - // Number of distinct LOIs with a pending or in-progress local mutation that isn't a delete. - val pendingNonDeletedLoiCount = - mutations - .asSequence() - .filter { it.syncStatus in setOf(SyncStatus.PENDING, SyncStatus.IN_PROGRESS) } - .filterNot { it.type == Mutation.Type.DELETE } - .map { it.locationOfInterestId } - .distinct() - .count() - - return remoteDataStore.countLois(survey, ownerUserId) < - localLoiStore.getLoiCount(survey.id) - pendingNonDeletedLoiCount + override suspend fun hasMissedRemoteDeletions(survey: Survey): Boolean { + val expectedRemoteCount = + localLoiStore.getLoiCount(survey.id) - localLoiStore.countPendingNonDeletedLois(survey.id) + if (expectedRemoteCount <= 0) return false + + val ownerUserId = authenticationManager.getAuthenticatedUser().id + return remoteDataStore.countLois(survey, ownerUserId) < expectedRemoteCount } override suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest? { diff --git a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt index 02a83071a5..063f6a7d03 100644 --- a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt @@ -42,7 +42,6 @@ import org.groundplatform.domain.model.SurveyListItem import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User -import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface import org.groundplatform.domain.repository.SurveyRepositoryInterface import timber.log.Timber @@ -96,20 +95,18 @@ constructor( override suspend fun recordSyncState( survey: Survey, - loiSyncResult: LocationOfInterestRepositoryInterface.SyncResult, + mode: SurveySyncMode, + latestLoiServerTimestamp: Long, ) { - when (loiSyncResult.mode) { + when (mode) { is SurveySyncMode.Full -> localSurveySyncStateStore.recordFullSync( survey.id, - loiSyncResult.latestLoiServerTimestamp, + latestLoiServerTimestamp, survey.dataVisibility, ) is SurveySyncMode.Incremental -> - localSurveySyncStateStore.recordIncrementalSync( - survey.id, - loiSyncResult.latestLoiServerTimestamp, - ) + localSurveySyncStateStore.recordIncrementalSync(survey.id, latestLoiServerTimestamp) } } diff --git a/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt index 4fe08b6936..5ac45d5936 100644 --- a/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt @@ -267,27 +267,15 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { SurveySyncMode.Incremental(0), ) - assertThat(result.latestLoiServerTimestamp).isEqualTo(SERVER_TIMESTAMP) + assertThat(result).isEqualTo(SERVER_TIMESTAMP) } @Test - fun `sync re-reads everything when the remote loi count has fallen behind`() = - runWithTestDispatcher { - val remaining = TEST_LOCATIONS_OF_INTEREST - TEST_POINT_OF_INTEREST_1 - fakeRemoteDataStore.predefinedLois = remaining - fakeRemoteDataStore.loiCount = { remaining.size.toLong() } - - val result = - locationOfInterestRepository.syncLocationsOfInterest( - TEST_SURVEY, - SurveySyncMode.Incremental(0), - ) + fun `a shrunken remote loi count is reported as a missed deletion`() = runWithTestDispatcher { + fakeRemoteDataStore.loiCount = { (TEST_LOCATIONS_OF_INTEREST.size - 1).toLong() } - assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) - .containsExactlyElementsIn(remaining) - // The caller asked for an incremental read and has to hear that it got a full one. - assertThat(result.mode).isEqualTo(SurveySyncMode.Full) - } + assertThat(locationOfInterestRepository.hasMissedRemoteDeletions(TEST_SURVEY)).isTrue() + } @Test fun `sync keeps local lois when the counts agree`() = runWithTestDispatcher { @@ -321,26 +309,18 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { } @Test - fun `sync ignores a count gap left by an loi held back by a pending mutation`() = + fun `a count gap left by an loi held back by a pending mutation is not a missed deletion`() = runWithTestDispatcher { locationOfInterestRepository.applyAndEnqueue( TEST_POINT_OF_INTEREST_1.toMutation(UPDATE, TEST_USER.id) ) - fakeRemoteDataStore.predefinedLois = emptyList() fakeRemoteDataStore.loiCount = { (TEST_LOCATIONS_OF_INTEREST.size - 1).toLong() } - val result = - locationOfInterestRepository.syncLocationsOfInterest( - TEST_SURVEY, - SurveySyncMode.Incremental(0), - ) - - // The read stayed incremental, so no re-read of everything was triggered. - assertThat(result.mode).isEqualTo(SurveySyncMode.Incremental(0)) + assertThat(locationOfInterestRepository.hasMissedRemoteDeletions(TEST_SURVEY)).isFalse() } @Test - fun `sync re-reads everything when a deletion is missed while a delete waits to upload`() = + fun `a missed deletion is still reported while a delete waits to upload`() = runWithTestDispatcher { // Deleted locally, so it already left the local count and can't hide the missed deletion. locationOfInterestRepository.applyAndEnqueue( @@ -348,33 +328,23 @@ class LocationOfInterestRepositoryTest : BaseHiltTest() { ) val remaining = TEST_LOCATIONS_OF_INTEREST - TEST_POINT_OF_INTEREST_1 - TEST_POINT_OF_INTEREST_2 - fakeRemoteDataStore.predefinedLois = remaining fakeRemoteDataStore.loiCount = { remaining.size.toLong() } - locationOfInterestRepository.syncLocationsOfInterest( - TEST_SURVEY, - SurveySyncMode.Incremental(0), - ) - - assertThat(locationOfInterestRepository.getValidLois(TEST_SURVEY).first()) - .containsExactlyElementsIn(remaining) + assertThat(locationOfInterestRepository.hasMissedRemoteDeletions(TEST_SURVEY)).isTrue() } @Test - fun `sync does not count remote lois during a full sync`() = runWithTestDispatcher { + fun `sync reads the lois it was asked for without counting them`() = runWithTestDispatcher { var counted = 0 fakeRemoteDataStore.loiCount = { counted++ TEST_LOCATIONS_OF_INTEREST.size.toLong() } - // A full sync already drops whatever the server stopped returning, so a count adds nothing. locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) - assertThat(counted).isEqualTo(0) - - // An incremental one leaves deletions behind, so it has to look. locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Incremental(0)) - assertThat(counted).isEqualTo(1) + + assertThat(counted).isEqualTo(0) } @Test diff --git a/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt index 22419db1de..82560b98f3 100644 --- a/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt +++ b/app/src/test/java/org/groundplatform/android/repository/SurveyRepositoryTest.kt @@ -27,7 +27,6 @@ import org.groundplatform.android.data.local.stores.LocalSurveyStore import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveySyncMode -import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.usecases.survey.ActivateSurveyUseCase import org.junit.Before @@ -64,10 +63,7 @@ class SurveyRepositoryTest : BaseHiltTest() { val survey = SURVEY.copy(dataVisibility = Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) localSurveyStore.insertOrUpdateSurvey(survey) - surveyRepository.recordSyncState( - survey, - SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP), - ) + surveyRepository.recordSyncState(survey, SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP) val state = checkNotNull(surveyRepository.getSyncState(survey.id)) assertThat(state.latestLoiServerTimestamp).isEqualTo(TEST_LATEST_LOI_TIMESTAMP) @@ -80,18 +76,13 @@ class SurveyRepositoryTest : BaseHiltTest() { runWithTestDispatcher { val survey = SURVEY.copy(dataVisibility = Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) localSurveyStore.insertOrUpdateSurvey(survey) - surveyRepository.recordSyncState( - survey, - SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP), - ) + surveyRepository.recordSyncState(survey, SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP) val afterFullRead = checkNotNull(surveyRepository.getSyncState(survey.id)) surveyRepository.recordSyncState( survey, - SyncResult( - SurveySyncMode.Incremental(TEST_LATEST_LOI_TIMESTAMP), - TEST_LATEST_LOI_TIMESTAMP + 1, - ), + SurveySyncMode.Incremental(TEST_LATEST_LOI_TIMESTAMP), + TEST_LATEST_LOI_TIMESTAMP + 1, ) val state = checkNotNull(surveyRepository.getSyncState(survey.id)) diff --git a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt index 760131ea91..ffdbf119e2 100644 --- a/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/home/mapcontainer/HomeScreenMapContainerViewModelTest.kt @@ -42,12 +42,10 @@ import org.groundplatform.android.ui.home.mapcontainer.jobs.AdHocDataCollectionB import org.groundplatform.android.ui.home.mapcontainer.jobs.JobMapComponentState import org.groundplatform.android.ui.home.mapcontainer.jobs.SelectedLoiSheetData import org.groundplatform.domain.model.Survey -import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.geometry.Coordinates import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.map.CameraPosition import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface -import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.usecases.survey.ActivateSurveyUseCase import org.groundplatform.domain.util.Constants.CLUSTERING_ZOOM_THRESHOLD @@ -86,8 +84,7 @@ class HomeScreenMapContainerViewModelTest : BaseHiltTest() { remoteDataStore.surveys = listOf(SURVEY) remoteDataStore.predefinedLois = listOf(LOCATION_OF_INTEREST) // Activating a survey syncs its LOIs, and the sync reports back where it left off. - whenever(loiRepository.syncLocationsOfInterest(any(), any())) - .thenReturn(SyncResult(SurveySyncMode.Full, latestLoiServerTimestamp = 0)) + whenever(loiRepository.syncLocationsOfInterest(any(), any())).thenReturn(0L) activateSurvey(SURVEY.id) advanceUntilIdle() whenever(loiRepository.getWithinBounds(SURVEY, BOUNDS)) diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt index c306f9f6b9..73b575d52a 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/LocationOfInterestRepositoryInterface.kt @@ -25,13 +25,14 @@ import org.groundplatform.domain.model.map.Bounds import org.groundplatform.domain.model.mutation.LocationOfInterestMutation interface LocationOfInterestRepositoryInterface { - data class SyncResult(val mode: SurveySyncMode, val latestLoiServerTimestamp: Long) - /** * Mirrors locations of interest in the specified survey from the remote db into the local db, - * reading as much of them as [mode] calls for, and returns where that left the sync. + * reading as much of them as [mode] calls for. Returns the newest server timestamp it saw. */ - suspend fun syncLocationsOfInterest(survey: Survey, mode: SurveySyncMode): SyncResult + suspend fun syncLocationsOfInterest(survey: Survey, mode: SurveySyncMode): Long + + /** Returns whether the local db holds an LOI which a full sync would find gone from remote. */ + suspend fun hasMissedRemoteDeletions(survey: Survey): Boolean /** This only works if the survey and location of interests are already cached to local db. */ suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest? diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt index 80d40b97d8..e8cd7297e7 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/SurveyRepositoryInterface.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User @@ -36,11 +37,8 @@ interface SurveyRepositoryInterface { /** Returns what the last sync of the given survey left behind, or null if none has run. */ suspend fun getSyncState(surveyId: String): SurveySyncState? - /** Records where [loiSyncResult] left the sync of [survey], for the next one to resume from. */ - suspend fun recordSyncState( - survey: Survey, - loiSyncResult: LocationOfInterestRepositoryInterface.SyncResult, - ) + /** Records where a [mode] sync of [survey] left off, for the next one to resume from. */ + suspend fun recordSyncState(survey: Survey, mode: SurveySyncMode, latestLoiServerTimestamp: Long) suspend fun getRemoteSurvey(surveyId: String): Survey? diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt index 8c35e653d6..e12d98e979 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCase.kt @@ -46,8 +46,9 @@ class SyncSurveyUseCase( private suspend fun syncSurvey(survey: Survey) { surveyRepository.saveSurvey(survey) - val result = loiRepository.syncLocationsOfInterest(survey, syncMode(survey)) - surveyRepository.recordSyncState(survey, result) + val mode = syncMode(survey) + val latestLoiServerTimestamp = loiRepository.syncLocationsOfInterest(survey, mode) + surveyRepository.recordSyncState(survey, mode, latestLoiServerTimestamp) Logger.d("Synced survey ${survey.id}") } @@ -58,11 +59,12 @@ class SyncSurveyUseCase( survey.dataVisibility != syncState.syncedDataVisibility -> SurveySyncMode.Full Clock.System.now().toEpochMilliseconds() - syncState.lastFullSyncClientTimestamp > FULL_SYNC_INTERVAL_MILLIS -> SurveySyncMode.Full + loiRepository.hasMissedRemoteDeletions(survey) -> SurveySyncMode.Full else -> SurveySyncMode.Incremental(syncState.latestLoiServerTimestamp) } } - private companion object { + internal companion object { // Periodic full survey reads prevent local incremental syncs from drifting from the server. val FULL_SYNC_INTERVAL_MILLIS = 7.days.inWholeMilliseconds } diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt index 47f9f8f1b6..1fd00c5fff 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/SyncSurveyUseCaseTest.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.test.runTest import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.SurveySyncState -import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.testing.FakeDataGenerator import org.groundplatform.testing.FakeLocationOfInterestRepository import org.groundplatform.testing.FakeSurveyRepository @@ -112,18 +111,37 @@ class SyncSurveyUseCaseTest { assertEquals(SurveySyncMode.Incremental(TEST_LATEST_LOI_TIMESTAMP), executeSync(state)) } + @Test + fun `does not look for missed deletions when a full read is already due`() = runTest { + executeSync(syncState = null) + + assertEquals(0, loiRepository.hasMissedRemoteDeletionsCall.callCount) + } + + @Test + fun `reads every LOI when a deletion was missed`() = runTest { + loiRepository.hasMissedRemoteDeletionsCall.overrideBehavior { true } + val state = + SurveySyncState( + surveyId = FakeDataGenerator.newSurvey().id, + latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP, + lastFullSyncClientTimestamp = Clock.System.now().toEpochMilliseconds(), + syncedDataVisibility = null, + ) + + assertEquals(SurveySyncMode.Full, executeSync(state)) + } + @Test fun `records where the sync of the LOIs left off`() = runTest { val survey = FakeDataGenerator.newSurvey() surveyRepository.remoteSurveys = listOf(survey) - loiRepository.syncResult = SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP) + loiRepository.latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP syncSurvey(survey.id) - assertEquals( - SyncResult(SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP), - surveyRepository.lastRecordedSyncState, - ) + assertEquals(SurveySyncMode.Full, surveyRepository.lastRecordedSyncMode) + assertEquals(TEST_LATEST_LOI_TIMESTAMP, surveyRepository.lastRecordedLoiServerTimestamp) } private suspend fun executeSync(syncState: SurveySyncState?): SurveySyncMode? { diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt index bd875b6849..636afa83ae 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeLocationOfInterestRepository.kt @@ -30,22 +30,23 @@ class FakeLocationOfInterestRepository : LocationOfInterestRepositoryInterface { var offlineLoi = FakeDataGenerator.newLocationOfInterest() var hasValidLois = true - var syncResult: LocationOfInterestRepositoryInterface.SyncResult? = null + var latestLoiServerTimestamp = 0L + + val hasMissedRemoteDeletionsCall = FakeCall { false } val syncLocationsOfInterestCall = FakeCall {} var lastSyncMode: SurveySyncMode? = null - override suspend fun syncLocationsOfInterest( - survey: Survey, - mode: SurveySyncMode, - ): LocationOfInterestRepositoryInterface.SyncResult { + override suspend fun syncLocationsOfInterest(survey: Survey, mode: SurveySyncMode): Long { lastSyncMode = mode syncLocationsOfInterestCall(survey) - return syncResult - ?: LocationOfInterestRepositoryInterface.SyncResult(mode, latestLoiServerTimestamp = 0) + return latestLoiServerTimestamp } + override suspend fun hasMissedRemoteDeletions(survey: Survey) = + hasMissedRemoteDeletionsCall(survey) + override suspend fun getOfflineLoi(surveyId: String, loiId: String): LocationOfInterest = offlineLoi diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt index fda4fda6be..71d98d75d7 100644 --- a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeSurveyRepository.kt @@ -21,9 +21,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.SurveySyncMode import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User -import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface.SyncResult import org.groundplatform.domain.repository.SurveyRepositoryInterface class FakeSurveyRepository : SurveyRepositoryInterface { @@ -45,7 +45,9 @@ class FakeSurveyRepository : SurveyRepositoryInterface { var syncState: SurveySyncState? = null - var lastRecordedSyncState: SyncResult? = null + var lastRecordedSyncMode: SurveySyncMode? = null + + var lastRecordedLoiServerTimestamp: Long? = null val remoteListItemsFlow = MutableStateFlow>(emptyList()) var remoteListItems: List @@ -67,8 +69,13 @@ class FakeSurveyRepository : SurveyRepositoryInterface { override suspend fun getSyncState(surveyId: String): SurveySyncState? = syncState - override suspend fun recordSyncState(survey: Survey, loiSyncResult: SyncResult) { - lastRecordedSyncState = loiSyncResult + override suspend fun recordSyncState( + survey: Survey, + mode: SurveySyncMode, + latestLoiServerTimestamp: Long, + ) { + lastRecordedSyncMode = mode + lastRecordedLoiServerTimestamp = latestLoiServerTimestamp } override suspend fun getRemoteSurvey(surveyId: String): Survey? = onGetRemoteSurveyCall(surveyId) From 33a3e5035029869822f4187a0397b6158ad02d5c Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 8 Sep 2026 22:44:43 +0200 Subject: [PATCH 14/14] fix code style --- .../local/room/stores/RoomSurveySyncStateStore.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt index f51fcd6f00..8db8e6d8b8 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt @@ -28,19 +28,17 @@ import org.groundplatform.domain.model.SurveySyncState class RoomSurveySyncStateStore @Inject constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyncStateStore { - override suspend fun get(surveyId: String): SurveySyncState? { - return surveySyncStateDao.get(surveyId)?.toModelObject() - } + override suspend fun get(surveyId: String): SurveySyncState? = + surveySyncStateDao.get(surveyId)?.toModelObject() - override suspend fun recordIncrementalSync(surveyId: String, latestLoiServerTimestamp: Long) { + override suspend fun recordIncrementalSync(surveyId: String, latestLoiServerTimestamp: Long) = surveySyncStateDao.updateLatestLoiServerTimestamp(surveyId, latestLoiServerTimestamp) - } override suspend fun recordFullSync( surveyId: String, latestLoiServerTimestamp: Long, dataVisibility: Survey.DataVisibility?, - ) { + ) = surveySyncStateDao.insertOrUpdate( SurveySyncState( surveyId = surveyId, @@ -50,5 +48,4 @@ constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyn ) .toLocalDataStoreObject() ) - } }