Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()?.number,
)

fun Task.toLocalDataStoreObject(jobId: String?) =
TaskEntity(
id = id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,4 +40,17 @@ interface LocationOfInterestMutationDao : BaseDao<LocationOfInterestMutationEnti
locationOfInterestId: String,
vararg allowedStates: MutationEntitySyncStatus,
): List<LocationOfInterestMutationEntity>

/** 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
}
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 @@ -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
Expand Down Expand Up @@ -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<String>) {
val idsToKeep = ids.toSet()
localDatabase.withTransaction {
Expand Down
Comment thread
shobhitagarwal1612 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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,
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,10 @@ interface LocalLocationOfInterestStore :
suspend fun insertOrUpdateAll(lois: List<LocationOfInterest>)

suspend fun deleteNotIn(surveyId: String, ids: List<String>)

/**
* 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
}
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
andreia-ferreira marked this conversation as resolved.
/** 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?,
)
}
Loading
Loading