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..9f3aceaae6 --- /dev/null +++ b/app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json @@ -0,0 +1,1176 @@ +{ + "formatVersion": 1, + "database": { + "version": 129, + "identityHash": "fe1334c01a767da11a41a4bf7878daaf", + "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` INTEGER, 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": "INTEGER" + } + ], + "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, 'fe1334c01a767da11a41a4bf7878daaf')" + ] + } +} \ No newline at end of file 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/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/converter/ConverterExt.kt b/app/src/main/java/org/groundplatform/android/data/local/room/converter/ConverterExt.kt index ee1d5f814c..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 @@ -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/LocationOfInterestMutationDao.kt b/app/src/main/java/org/groundplatform/android/data/local/room/dao/LocationOfInterestMutationDao.kt index 03db3d0d39..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 @@ -39,4 +40,28 @@ 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 + + /** 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/dao/SurveySyncStateDao.kt b/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt new file mode 100644 index 0000000000..8914fa19ee --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/dao/SurveySyncStateDao.kt @@ -0,0 +1,31 @@ +/* + * 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 new file mode 100644 index 0000000000..bc997ee90f --- /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: 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 d34b17853b..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,12 +144,31 @@ 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 + // 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/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..8db8e6d8b8 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt @@ -0,0 +1,51 @@ +/* + * 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.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.stores.LocalSurveySyncStateStore +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? = + surveySyncStateDao.get(surveyId)?.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( + 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/data/local/stores/LocalSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt new file mode 100644 index 0000000000..430af2ae1d --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.kt @@ -0,0 +1,31 @@ +/* + * 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.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/data/remote/RemoteDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/RemoteDataStore.kt index b791d91934..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 @@ -50,15 +50,22 @@ 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> + + /** 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 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..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 @@ -84,13 +87,29 @@ internal constructor( ) } - override fun loadPredefinedLois(survey: Survey) = - fetchLoiPages(survey) { fetchPredefined(survey) } - - override fun loadUserLois(survey: Survey, ownerUserId: String) = - fetchLoiPages(survey) { fetchUserDefined(survey, ownerUserId) } - - override fun loadSharedLois(survey: Survey) = fetchLoiPages(survey) { fetchSharedLois(survey) } + override fun loadPredefinedLois(survey: Survey, fromTimestamp: Long?) = + fetchLoiPages(survey) { fetch(survey, LoiQueryScope.Predefined, fromTimestamp) } + + override fun loadUserLois(survey: Survey, ownerUserId: String, fromTimestamp: Long?) = + fetchLoiPages(survey) { fetch(survey, LoiQueryScope.UserDefined(ownerUserId), fromTimestamp) } + + override fun loadSharedLois(survey: Survey, fromTimestamp: Long?) = + 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 0490ccf9cf..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,15 +17,18 @@ 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 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 +41,14 @@ 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. */ +@VisibleForTesting +internal 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 @@ -45,45 +56,69 @@ const val OWNER_FIELD = LocationOfInterestProto.OWNER_ID_FIELD_NUMBER.toString() */ @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): 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), - ) - - /** Emits LOIs created by the specified email in the specified survey, a page at a time. */ - fun fetchUserDefined(survey: Survey, ownerUserId: String): Flow> = - fetchLois( - survey, - reference() - .whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.FIELD_DATA.number) - .whereEqualTo(OWNER_FIELD, ownerUserId), - ) - - /** Emits all LOIs visible to data collectors in the given survey, a page at a time. */ - fun fetchSharedLois(survey: Survey): Flow> = - fetchLois( - survey, - reference().whereEqualTo(SOURCE_FIELD, LocationOfInterestProto.Source.FIELD_DATA.number), - ) + /** Emits the survey's LOIs in [scope], one page at a time. */ + internal fun fetch( + survey: Survey, + scope: LoiQueryScope, + fromTimestamp: Long?, + ): 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 * 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 + .whereGreaterThanOrEqualTo(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 +132,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/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt b/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt index 729e3928a7..d368d21c9a 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,10 @@ 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 +144,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/repository/LocationOfInterestRepository.kt b/app/src/main/java/org/groundplatform/android/repository/LocationOfInterestRepository.kt index b4d123adb8..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 @@ -29,6 +28,7 @@ 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.SurveySyncMode import org.groundplatform.domain.model.geometry.Geometry import org.groundplatform.domain.model.job.Job import org.groundplatform.domain.model.locationofinterest.LocationOfInterest @@ -58,44 +58,50 @@ constructor( private val uuidGenerator: OfflineUuidGenerator, private val authenticationManager: AuthenticationManager, ) : LocationOfInterestRepositoryInterface { - override suspend fun syncLocationsOfInterest(survey: Survey) { - val ownerUserId = authenticationManager.getAuthenticatedUser().id + 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. */ + 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() - 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() + if (mode is SurveySyncMode.Full) { + localLoiStore.deleteNotIn(survey.id, syncedLoiIds) + } - // Delete LOIs in local db not returned in latest list from server, skipping pending mutations. - localLoiStore.deleteNotIn(survey.id, syncedLoiIds.toList() + pendingLois) + return 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 } - } - return savedIds + 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 0539a2edc4..063f6a7d03 100644 --- a/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/SurveyRepository.kt @@ -34,10 +34,13 @@ 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.SurveySyncMode +import org.groundplatform.domain.model.SurveySyncState import org.groundplatform.domain.model.User import org.groundplatform.domain.repository.SurveyRepositoryInterface import timber.log.Timber @@ -53,6 +56,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 +90,26 @@ constructor( override fun getOfflineSurveys(): Flow> = localSurveyStore.surveys + override suspend fun getSyncState(surveyId: String): SurveySyncState? = + localSurveySyncStateStore.get(surveyId) + + override suspend fun recordSyncState( + survey: Survey, + mode: SurveySyncMode, + latestLoiServerTimestamp: Long, + ) { + when (mode) { + is SurveySyncMode.Full -> + localSurveySyncStateStore.recordFullSync( + survey.id, + latestLoiServerTimestamp, + survey.dataVisibility, + ) + is SurveySyncMode.Incremental -> + localSurveySyncStateStore.recordIncrementalSync(survey.id, latestLoiServerTimestamp) + } + } + override suspend fun removeOfflineSurvey(surveyId: String) { getOfflineSurvey(surveyId)?.let { localSurveyStore.deleteSurvey(it) } } 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/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/repository/LocationOfInterestRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/LocationOfInterestRepositoryTest.kt index 5e8c04239f..5ac45d5936 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,119 @@ 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).isEqualTo(SERVER_TIMESTAMP) + } + + @Test + fun `a shrunken remote loi count is reported as a missed deletion`() = runWithTestDispatcher { + fakeRemoteDataStore.loiCount = { (TEST_LOCATIONS_OF_INTEREST.size - 1).toLong() } + + assertThat(locationOfInterestRepository.hasMissedRemoteDeletions(TEST_SURVEY)).isTrue() + } + + @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 `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.loiCount = { (TEST_LOCATIONS_OF_INTEREST.size - 1).toLong() } + + assertThat(locationOfInterestRepository.hasMissedRemoteDeletions(TEST_SURVEY)).isFalse() + } + + @Test + 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( + 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.loiCount = { remaining.size.toLong() } + + assertThat(locationOfInterestRepository.hasMissedRemoteDeletions(TEST_SURVEY)).isTrue() + } + + @Test + 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() + } + + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Full) + locationOfInterestRepository.syncLocationsOfInterest(TEST_SURVEY, SurveySyncMode.Incremental(0)) + + assertThat(counted).isEqualTo(0) + } + @Test fun `loi within bounds when out of bounds returns empty list`() = runWithTestDispatcher { val southwest = Coordinates(-60.0, -60.0) @@ -377,6 +487,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..82560b98f3 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,8 @@ 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.SurveyRepositoryInterface import org.groundplatform.domain.usecases.survey.ActivateSurveyUseCase import org.junit.Before @@ -47,6 +49,49 @@ 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, 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, SurveySyncMode.Full, TEST_LATEST_LOI_TIMESTAMP) + val afterFullRead = checkNotNull(surveyRepository.getSyncState(survey.id)) + + surveyRepository.recordSyncState( + survey, + 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 +140,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..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 @@ -83,6 +83,8 @@ 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(0L) activateSurvey(SURVEY.id) advanceUntilIdle() whenever(loiRepository.getWithinBounds(SURVEY, BOUNDS)) 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/model/SurveySyncState.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncState.kt new file mode 100644 index 0000000000..3bda9b96e3 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/SurveySyncState.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 + +data class SurveySyncState( + val surveyId: String, + val latestLoiServerTimestamp: Long, + val lastFullSyncClientTimestamp: Long, + val syncedDataVisibility: Survey.DataVisibility?, +) 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..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 @@ -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,14 @@ 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) + /** + * 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. Returns the newest server timestamp it saw. + */ + 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 b2daa0daeb..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,8 @@ 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 /** Maintains the state of currently active survey. */ @@ -32,6 +34,12 @@ 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 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? fun getRemoteSurveys(user: User): Flow> 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..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 @@ -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 @@ -43,7 +46,26 @@ class SyncSurveyUseCase( private suspend fun syncSurvey(survey: Survey) { surveyRepository.saveSurvey(survey) - loiRepository.syncLocationsOfInterest(survey) + val mode = syncMode(survey) + val latestLoiServerTimestamp = loiRepository.syncLocationsOfInterest(survey, mode) + surveyRepository.recordSyncState(survey, mode, latestLoiServerTimestamp) 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 + loiRepository.hasMissedRemoteDeletions(survey) -> SurveySyncMode.Full + else -> SurveySyncMode.Incremental(syncState.latestLoiServerTimestamp) + } + } + + 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/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..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 @@ -20,7 +20,11 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock 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.testing.FakeDataGenerator import org.groundplatform.testing.FakeLocationOfInterestRepository import org.groundplatform.testing.FakeSurveyRepository @@ -57,4 +61,100 @@ 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() - + SyncSurveyUseCase.FULL_SYNC_INTERVAL_MILLIS * 2, + 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() - + SyncSurveyUseCase.FULL_SYNC_INTERVAL_MILLIS / 2, + syncedDataVisibility = null, + ) + + 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.latestLoiServerTimestamp = TEST_LATEST_LOI_TIMESTAMP + + syncSurvey(survey.id) + + assertEquals(SurveySyncMode.Full, surveyRepository.lastRecordedSyncMode) + assertEquals(TEST_LATEST_LOI_TIMESTAMP, surveyRepository.lastRecordedLoiServerTimestamp) + } + + 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..636afa83ae 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,12 +30,23 @@ class FakeLocationOfInterestRepository : LocationOfInterestRepositoryInterface { var offlineLoi = FakeDataGenerator.newLocationOfInterest() var hasValidLois = true + var latestLoiServerTimestamp = 0L + + val hasMissedRemoteDeletionsCall = FakeCall { false } + val syncLocationsOfInterestCall = FakeCall {} - override suspend fun syncLocationsOfInterest(survey: Survey) { + var lastSyncMode: SurveySyncMode? = null + + override suspend fun syncLocationsOfInterest(survey: Survey, mode: SurveySyncMode): Long { + lastSyncMode = mode syncLocationsOfInterestCall(survey) + 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 4ddd59a085..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,6 +21,8 @@ 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.SurveyRepositoryInterface @@ -41,6 +43,12 @@ class FakeSurveyRepository : SurveyRepositoryInterface { var remoteSurveys: List = emptyList() + var syncState: SurveySyncState? = null + + var lastRecordedSyncMode: SurveySyncMode? = null + + var lastRecordedLoiServerTimestamp: Long? = null + val remoteListItemsFlow = MutableStateFlow>(emptyList()) var remoteListItems: List get() = remoteListItemsFlow.value @@ -59,6 +67,17 @@ 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, + mode: SurveySyncMode, + latestLoiServerTimestamp: Long, + ) { + lastRecordedSyncMode = mode + lastRecordedLoiServerTimestamp = latestLoiServerTimestamp + } + override suspend fun getRemoteSurvey(surveyId: String): Survey? = onGetRemoteSurveyCall(surveyId) override fun getRemoteSurveys(user: User): Flow> = remoteListItemsFlow