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..d46c789143 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()?.number, + ) + 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..3e5cf13b99 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,17 @@ interface LocationOfInterestMutationDao : BaseDao + + /** Returns how many of the survey's LOIs hold a mutation of another type in one of the states. */ + @Query( + "SELECT COUNT(DISTINCT location_of_interest_id) FROM location_of_interest_mutation " + + "WHERE survey_id = :surveyId " + + "AND type != :excludedType " + + "AND state IN (:allowedStates)" + ) + suspend fun countLocationOfInterestIds( + surveyId: String, + excludedType: MutationEntityType, + vararg allowedStates: MutationEntitySyncStatus, + ): Int } diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/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..49a8728d0c 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,6 +144,14 @@ class RoomLocationOfInterestStore @Inject internal constructor() : LocalLocation locationOfInterestDao.upsertAll(entities) } + override suspend fun countPendingNonDeletedLois(surveyId: String): Int = + locationOfInterestMutationDao.countLocationOfInterestIds( + surveyId, + MutationEntityType.DELETE, + MutationEntitySyncStatus.PENDING, + MutationEntitySyncStatus.IN_PROGRESS, + ) + override suspend fun deleteNotIn(surveyId: String, ids: List) { val idsToKeep = ids.toSet() localDatabase.withTransaction { diff --git a/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt new file mode 100644 index 0000000000..716833a526 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/room/stores/RoomSurveySyncStateStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.data.local.room.stores + +import javax.inject.Inject +import kotlin.time.Clock +import org.groundplatform.android.data.local.room.converter.toModelObject +import org.groundplatform.android.data.local.room.dao.SurveySyncStateDao +import org.groundplatform.android.data.local.room.dao.insertOrUpdate +import org.groundplatform.android.data.local.room.entity.SurveySyncStateEntity +import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore +import org.groundplatform.android.data.remote.firebase.protobuf.toProto +import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncState + +class RoomSurveySyncStateStore +@Inject +constructor(private val surveySyncStateDao: SurveySyncStateDao) : LocalSurveySyncStateStore { + override suspend fun get(surveyId: String): SurveySyncState? { + val entity = surveySyncStateDao.get(surveyId) + return entity?.toModelObject() + } + + override suspend fun recordIncrementalSync( + surveyId: String, + latestLoiServerTimestamp: Long, + ) { + surveySyncStateDao.updateLatestLoiServerTimestamp(surveyId, latestLoiServerTimestamp) + } + + override suspend fun recordFullSync( + surveyId: String, + latestLoiServerTimestamp: Long, + dataVisibility: Survey.DataVisibility?, + ) { + surveySyncStateDao.insertOrUpdate( + SurveySyncStateEntity( + surveyId = surveyId, + latestLoiServerTimestamp = latestLoiServerTimestamp, + lastFullSyncClientTimestamp = Clock.System.now().toEpochMilliseconds(), + syncedDataVisibility = dataVisibility?.toProto()?.ordinal, + ) + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/data/local/stores/LocalLocationOfInterestStore.kt b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalLocationOfInterestStore.kt index 1ae85fae0d..f86215a65a 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 @@ -66,4 +66,10 @@ interface LocalLocationOfInterestStore : suspend fun insertOrUpdateAll(lois: List) suspend fun deleteNotIn(surveyId: String, ids: List) + + /** + * 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..06563a5f44 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/data/local/stores/LocalSurveySyncStateStore.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.stores + +import org.groundplatform.domain.model.Survey +import org.groundplatform.domain.model.SurveySyncState + +/** + * Provides access to [SurveySyncState] data in local storage. + * + * This keeps track of the latest LOI server timestamp that has been synced for a survey so a later + * sync can fetch only data that changed after that point. + */ +interface LocalSurveySyncStateStore { + /** Returns the sync state of the given survey, or null if it has never been fully synced. */ + suspend fun get(surveyId: String): SurveySyncState? + + /** + * Records the latest LOI server timestamp for an incremental sync without changing the rest of + * the survey's sync state. Does nothing if the survey has never been fully synced. + */ + suspend fun recordIncrementalSync( + surveyId: String, + latestLoiServerTimestamp: Long, + ) + + /** + * Records that the survey's LOIs were fully reconciled and replaces any existing state for it. + * [latestLoiServerTimestamp] becomes the latest server timestamp used for later incremental + * syncs, and [dataVisibility] stores the visibility used to fetch the data so later changes can + * be detected. + */ + suspend fun recordFullSync( + surveyId: String, + latestLoiServerTimestamp: Long, + dataVisibility: Survey.DataVisibility?, + ) +} diff --git a/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt b/app/src/main/java/org/groundplatform/android/di/LocalDataStoreModule.kt index 729e3928a7..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/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt b/app/src/test/java/org/groundplatform/android/data/local/LocalLocationOfInterestStoreTest.kt index 711603c747..f95873ff07 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 @@ -343,6 +343,60 @@ class LocalLocationOfInterestStoreTest : BaseHiltTest() { assertThat(localLoiStore.getLoiCount(OTHER_SURVEY.id)).isEqualTo(1) } + @Test + fun `countPendingNonDeletedLois counts an loi with unsynced changes once`() = + runWithTestDispatcher { + localUserStore.insertOrUpdateUser(TEST_USER) + localSurveyStore.insertOrUpdateSurvey(TEST_SURVEY) + localLoiStore.insertOrUpdate(testLoi("queued")) + localLoiStore.enqueue(queuedMutation("queued")) + // A second queued change to the same LOI must not count it twice. + localLoiStore.enqueue( + queuedMutation("queued", type = Mutation.Type.UPDATE, status = SyncStatus.IN_PROGRESS) + ) + + assertThat(localLoiStore.countPendingNonDeletedLois(TEST_SURVEY.id)).isEqualTo(1) + } + + @Test + fun `countPendingNonDeletedLois skips queued deletes and synced changes`() = + runWithTestDispatcher { + localUserStore.insertOrUpdateUser(TEST_USER) + localSurveyStore.insertOrUpdateSurvey(TEST_SURVEY) + localLoiStore.insertOrUpdate(testLoi("going away")) + localLoiStore.insertOrUpdate(testLoi("settled")) + localLoiStore.enqueue(queuedMutation("going away", type = Mutation.Type.DELETE)) + localLoiStore.enqueue(queuedMutation("settled", status = SyncStatus.COMPLETED)) + + assertThat(localLoiStore.countPendingNonDeletedLois(TEST_SURVEY.id)).isEqualTo(0) + } + + @Test + fun `countPendingNonDeletedLois still counts an loi that is also queued for both creation and deletion`() = + runWithTestDispatcher { + localUserStore.insertOrUpdateUser(TEST_USER) + localSurveyStore.insertOrUpdateSurvey(TEST_SURVEY) + localLoiStore.insertOrUpdate(testLoi("both")) + localLoiStore.enqueue(queuedMutation("both")) + // The delete row is skipped, but the create is not. + localLoiStore.enqueue(queuedMutation("both", type = Mutation.Type.DELETE)) + + assertThat(localLoiStore.countPendingNonDeletedLois(TEST_SURVEY.id)).isEqualTo(1) + } + + @Test + fun `countPendingNonDeletedLois only counts the survey it was asked about`() = + runWithTestDispatcher { + localUserStore.insertOrUpdateUser(TEST_USER) + localSurveyStore.insertOrUpdateSurvey(TEST_SURVEY) + localSurveyStore.insertOrUpdateSurvey(OTHER_SURVEY) + localLoiStore.insertOrUpdate(testLoi("other", surveyId = OTHER_SURVEY.id)) + localLoiStore.enqueue(queuedMutation("other", surveyId = OTHER_SURVEY.id)) + + assertThat(localLoiStore.countPendingNonDeletedLois(TEST_SURVEY.id)).isEqualTo(0) + assertThat(localLoiStore.countPendingNonDeletedLois(OTHER_SURVEY.id)).isEqualTo(1) + } + @Test fun `insertOrUpdateAll inserts new LOIs and updates existing ones`() = runWithTestDispatcher { localUserStore.insertOrUpdateUser(TEST_USER) @@ -412,6 +466,22 @@ class LocalLocationOfInterestStoreTest : BaseHiltTest() { } } + /** A mutation queued against an existing LOI, without touching the LOI itself. */ + private fun queuedMutation( + loiId: String, + surveyId: String = TEST_SURVEY.id, + type: Mutation.Type = Mutation.Type.CREATE, + status: SyncStatus = SyncStatus.PENDING, + ) = + FakeDataGenerator.newLoiMutation( + mutationType = type, + syncStatus = status, + surveyId = surveyId, + locationOfInterestId = loiId, + geometry = TEST_POINT, + ) + .copy(id = null) + private fun testLoi( id: String, surveyId: String = TEST_SURVEY.id, diff --git a/app/src/test/java/org/groundplatform/android/data/local/LocalSurveySyncStateStoreTest.kt b/app/src/test/java/org/groundplatform/android/data/local/LocalSurveySyncStateStoreTest.kt new file mode 100644 index 0000000000..73eb391af1 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/local/LocalSurveySyncStateStoreTest.kt @@ -0,0 +1,160 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.FakeData.SURVEY +import org.groundplatform.android.data.local.stores.LocalSurveyStore +import org.groundplatform.android.data.local.stores.LocalSurveySyncStateStore +import org.groundplatform.domain.model.Survey +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class LocalSurveySyncStateStoreTest : BaseHiltTest() { + + @Inject lateinit var localSurveyStore: LocalSurveyStore + @Inject lateinit var localSurveySyncStateStore: LocalSurveySyncStateStore + + @Test + fun `get returns null for a survey that was never synced`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + + assertThat(localSurveySyncStateStore.get(SURVEY.id)).isNull() + } + + @Test + fun `recordFullSync stores the sync state`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + + localSurveySyncStateStore.recordFullSync( + SURVEY.id, + latestLoiServerTimestamp = 1_234L, + dataVisibility = Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS, + ) + + val state = localSurveySyncStateStore.get(SURVEY.id)!! + assertThat(state.surveyId).isEqualTo(SURVEY.id) + assertThat(state.latestLoiServerTimestamp).isEqualTo(1_234L) + assertThat(state.syncedDataVisibility).isEqualTo(Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) + } + + @Test + fun `recordFullSync stamps the client timestamp with the current time`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + + val before = System.currentTimeMillis() + localSurveySyncStateStore.recordFullSync(SURVEY.id, 0L, null) + val after = System.currentTimeMillis() + + val recorded = localSurveySyncStateStore.get(SURVEY.id)!!.lastFullSyncClientTimestamp + assertThat(recorded).isAtLeast(before) + assertThat(recorded).isAtMost(after) + } + + @Test + fun `recordFullSync round trips every data visibility`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + + for (dataVisibility in Survey.DataVisibility.entries) { + localSurveySyncStateStore.recordFullSync(SURVEY.id, 1L, dataVisibility) + + assertThat(localSurveySyncStateStore.get(SURVEY.id)!!.syncedDataVisibility) + .isEqualTo(dataVisibility) + } + } + + @Test + fun `recordFullSync replaces the state of an already synced survey`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + localSurveySyncStateStore.recordFullSync( + SURVEY.id, + 10L, + Survey.DataVisibility.CONTRIBUTOR_AND_ORGANIZERS, + ) + val firstSync = localSurveySyncStateStore.get(SURVEY.id)!!.lastFullSyncClientTimestamp + + localSurveySyncStateStore.recordFullSync( + SURVEY.id, + 20L, + Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS, + ) + + val state = localSurveySyncStateStore.get(SURVEY.id)!! + assertThat(state.latestLoiServerTimestamp).isEqualTo(20L) + assertThat(state.syncedDataVisibility).isEqualTo(Survey.DataVisibility.ALL_SURVEY_PARTICIPANTS) + assertThat(state.lastFullSyncClientTimestamp).isAtLeast(firstSync) + } + + @Test + fun `recordIncrementalSync only advances the LOI timestamp`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + localSurveySyncStateStore.recordFullSync( + SURVEY.id, + 10L, + Survey.DataVisibility.CONTRIBUTOR_AND_ORGANIZERS, + ) + val fullSyncState = localSurveySyncStateStore.get(SURVEY.id)!! + + localSurveySyncStateStore.recordIncrementalSync(SURVEY.id, 20L) + + assertThat(localSurveySyncStateStore.get(SURVEY.id)) + .isEqualTo(fullSyncState.copy(latestLoiServerTimestamp = 20L)) + } + + @Test + fun `recordIncrementalSync does nothing for a survey that was never fully synced`() = + runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + + localSurveySyncStateStore.recordIncrementalSync(SURVEY.id, 20L) + + assertThat(localSurveySyncStateStore.get(SURVEY.id)).isNull() + } + + @Test + fun `sync state is tracked per survey`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + localSurveyStore.insertOrUpdateSurvey(OTHER_SURVEY) + + localSurveySyncStateStore.recordFullSync(SURVEY.id, 10L, null) + localSurveySyncStateStore.recordFullSync(OTHER_SURVEY.id, 20L, null) + localSurveySyncStateStore.recordIncrementalSync(SURVEY.id, 30L) + + assertThat(localSurveySyncStateStore.get(SURVEY.id)!!.latestLoiServerTimestamp).isEqualTo(30L) + assertThat(localSurveySyncStateStore.get(OTHER_SURVEY.id)!!.latestLoiServerTimestamp) + .isEqualTo(20L) + } + + @Test + fun `deleting a survey discards its sync state`() = runWithTestDispatcher { + localSurveyStore.insertOrUpdateSurvey(SURVEY) + localSurveySyncStateStore.recordFullSync(SURVEY.id, 10L, null) + + localSurveyStore.deleteSurvey(SURVEY) + + assertThat(localSurveySyncStateStore.get(SURVEY.id)).isNull() + } + + companion object { + private val OTHER_SURVEY = SURVEY.copy(id = "other survey id") + } +} 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?, +)