Skip to content
Draft
1,176 changes: 1,176 additions & 0 deletions app/schemas/org.groundplatform.android.data.local.room.LocalDatabase/129.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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<Migration>): LocalDatabase =
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -143,4 +147,6 @@ abstract class LocalDatabase : RoomDatabase() {
abstract fun conditionDao(): ConditionDao

abstract fun expressionDao(): ExpressionDao

abstract fun surveySyncStateDao(): SurveySyncStateDao
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,15 @@ interface LocationOfInterestMutationDao : BaseDao<LocationOfInterestMutationEnti
locationOfInterestId: String,
vararg allowedStates: MutationEntitySyncStatus,
): List<LocationOfInterestMutationEntity>

/** 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<String>
}
Original file line number Diff line number Diff line change
@@ -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<SurveySyncStateEntity> {
@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)
}
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,20 @@ class RoomLocationOfInterestStore @Inject internal constructor() : LocalLocation
override suspend fun deleteNotIn(surveyId: String, ids: List<String>) {
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) }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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,
)
)
}
}
Original file line number Diff line number Diff line change
@@ -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?,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,22 @@ interface RemoteDataStore {
suspend fun loadTermsOfService(): TermsOfService?

/** Returns predefined LOIs in the specified survey. Main-safe. */
fun loadPredefinedLois(survey: Survey): Flow<List<LocationOfInterest>>
fun loadPredefinedLois(survey: Survey, fromTimestamp: Long?): Flow<List<LocationOfInterest>>

/** Returns LOIs owned by the specified user in the specified survey. Main-safe. */
fun loadUserLois(survey: Survey, ownerUserId: String): Flow<List<LocationOfInterest>>
fun loadUserLois(
survey: Survey,
ownerUserId: String,
fromTimestamp: Long?,
): Flow<List<LocationOfInterest>>

/**
* Returns LOIs that have been marked as shared for other participants of the specified survey.
*/
fun loadSharedLois(survey: Survey): Flow<List<LocationOfInterest>>
fun loadSharedLois(survey: Survey, fromTimestamp: Long?): Flow<List<LocationOfInterest>>

/** 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class FirebaseMessagingService : FirebaseMessagingService() {
return
}
Timber.v("Message received from topic ${remoteMessage.from}")

surveySyncService.enqueueSync(surveyId)
}

Expand Down
Loading