Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@ open class DataStoreException(message: String?) : RuntimeException(message) {
}
}
}

class UpdateRequiredException : DataStoreException("App update required")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename to AppUpdateRequiredException?

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import org.groundplatform.android.BuildConfig.USE_EMULATORS
import org.groundplatform.android.data.remote.RemoteDataStore
import org.groundplatform.android.data.remote.UpdateRequiredException
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
Expand All @@ -48,6 +49,7 @@ import org.groundplatform.domain.model.mutation.LocationOfInterestMutation
import org.groundplatform.domain.model.mutation.Mutation
import org.groundplatform.domain.model.mutation.SubmissionMutation
import org.groundplatform.domain.model.toListItem
import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase
import timber.log.Timber

private const val PROFILE_REFRESH_CLOUD_FUNCTION_NAME = "profile-refresh"
Expand All @@ -58,10 +60,14 @@ class FirestoreDataStore
internal constructor(
private val firebaseFunctions: FirebaseFunctions,
private val firestoreProvider: FirebaseFirestoreProvider,
private val shouldForceUpdate: ShouldForceUpdateUseCase,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : RemoteDataStore {

private suspend fun db() = GroundFirestore(firestoreProvider.get())
private suspend fun db(): GroundFirestore {
if (shouldForceUpdate()) throw UpdateRequiredException()
return GroundFirestore(firestoreProvider.get())
}

override suspend fun loadSurvey(surveyId: String): Survey? =
withContext(ioDispatcher) { db().surveys().survey(surveyId).get() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.groundplatform.android.di.coroutines.IoDispatcher
import org.groundplatform.domain.repository.MutationRepositoryInterface
import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase
import timber.log.Timber

/**
Expand All @@ -41,11 +42,15 @@ constructor(
@Assisted params: WorkerParameters,
private val mutationRepository: MutationRepositoryInterface,
private val mediaUploadWorkManager: MediaUploadWorkManager,
private val shouldForceUpdate: ShouldForceUpdateUseCase,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : CoroutineWorker(context, params) {

override suspend fun doWork(): Result =
withContext(ioDispatcher) {
override suspend fun doWork(): Result {
// Changes stay queued locally and are uploaded once the updated app is opened.
if (shouldForceUpdate()) return success()

return withContext(ioDispatcher) {
val queue = mutationRepository.getIncompleteUploads()
Timber.d("Uploading ${queue.size} additions / changes")
val results = queue.map { mutationRepository.processMutations(it.mutations()) }
Expand All @@ -59,4 +64,5 @@ constructor(

if (results.size == successfulMutations.size) success() else retry()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.groundplatform.domain.model.mutation.SubmissionMutation
import org.groundplatform.domain.model.task.PhotoTaskData
import org.groundplatform.domain.repository.MutationRepositoryInterface
import org.groundplatform.domain.repository.UserMediaRepositoryInterface
import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase
import timber.log.Timber

/**
Expand All @@ -54,16 +55,21 @@ constructor(
private val remoteStorageManager: RemoteStorageManager,
private val mutationRepository: MutationRepositoryInterface,
private val userMediaRepository: UserMediaRepositoryInterface,
private val shouldForceUpdate: ShouldForceUpdateUseCase,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : CoroutineWorker(context, workerParams) {

override suspend fun doWork(): Result =
withContext(ioDispatcher) {
override suspend fun doWork(): Result {
// Media stays queued locally and is uploaded once the updated app is opened.
if (shouldForceUpdate()) return success()

return withContext(ioDispatcher) {
val mutations = mutationRepository.getIncompleteMediaMutations()
Timber.d("Uploading photos for ${mutations.size} submission mutations")
val results = mutations.map { uploadAllMedia(it) }
if (results.all { it }) success() else retry()
}
}

/**
* Upload all media associated with a given submission. Returns `true` if all uploads succeeds or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.groundplatform.android.di.coroutines.IoDispatcher
import org.groundplatform.domain.repository.SurveyRepositoryInterface
import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase
import org.groundplatform.domain.usecases.survey.SyncSurveyUseCase
import timber.log.Timber

Expand All @@ -46,6 +47,7 @@ constructor(
@Assisted params: WorkerParameters,
private val syncSurvey: SyncSurveyUseCase,
private val surveyRepository: SurveyRepositoryInterface,
private val shouldForceUpdate: ShouldForceUpdateUseCase,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : CoroutineWorker(context, params) {
private val surveyId: String? = params.inputData.getString(SURVEY_ID_PARAM_KEY)
Expand All @@ -58,6 +60,9 @@ constructor(
return failure()
}

// The first sync after the update catches up, so there is nothing to retry.
if (shouldForceUpdate()) return success()

if (runAttemptCount >= MAX_SYNC_ATTEMPTS) {
Timber.e("Giving up sync of survey $surveyId after $runAttemptCount attempts")
return failure()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import jakarta.inject.Singleton
import org.groundplatform.android.repository.AppConfigRepository
import org.groundplatform.android.repository.LocationOfInterestRepository
import org.groundplatform.android.repository.MapStateRepository
import org.groundplatform.android.repository.MutationRepository
Expand All @@ -29,6 +30,7 @@ import org.groundplatform.android.repository.SurveyRepository
import org.groundplatform.android.repository.TermsOfServiceRepository
import org.groundplatform.android.repository.UserMediaRepository
import org.groundplatform.android.repository.UserRepository
import org.groundplatform.domain.repository.AppConfigRepositoryInterface
import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface
import org.groundplatform.domain.repository.MapStateRepositoryInterface
import org.groundplatform.domain.repository.MutationRepositoryInterface
Expand Down Expand Up @@ -114,3 +116,11 @@ abstract class UserMediaRepositoryModule {
@Singleton
abstract fun bindUserMediaRepository(impl: UserMediaRepository): UserMediaRepositoryInterface
}

@Module
@InstallIn(SingletonComponent::class)
abstract class AppConfigRepositoryModule {
@Binds
@Singleton
abstract fun bindAppConfigRepository(impl: AppConfigRepository): AppConfigRepositoryInterface
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.groundplatform.android.BuildConfig
import org.groundplatform.domain.repository.AppConfigRepositoryInterface
import org.groundplatform.domain.repository.LocationOfInterestRepositoryInterface
import org.groundplatform.domain.repository.MapStateRepositoryInterface
import org.groundplatform.domain.repository.OfflineAreaRepositoryInterface
Expand Down Expand Up @@ -125,4 +127,11 @@ object UseCaseModule {
surveyRepository: SurveyRepositoryInterface,
userRepository: UserRepositoryInterface,
) = ListAvailableSurveysUseCase(networkManager, surveyRepository, userRepository)

@Provides
fun providesShouldForceUpdateUseCase(appConfigRepositoryInterface: AppConfigRepositoryInterface) =
org.groundplatform.domain.usecases.ShouldForceUpdateUseCase(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we import the use case instead of using fully qualified imports?

appConfigRepository = appConfigRepositoryInterface,
currentVersion = BuildConfig.VERSION_NAME,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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.repository

import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import javax.inject.Inject
import javax.inject.Singleton
import org.groundplatform.domain.model.AppConfig
import org.groundplatform.domain.repository.AppConfigRepositoryInterface

/** Reads the [AppConfig] from Firebase Remote Config. */
@Singleton
class AppConfigRepository @Inject constructor(private val remoteConfig: FirebaseRemoteConfig) :
AppConfigRepositoryInterface {

override fun getAppConfig(): AppConfig =
AppConfig(
minAppVersion = remoteConfig.getString("min_app_version"),
forceUpdate = remoteConfig.getBoolean("force_update"),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ class MainActivity : AbstractActivity() {

override fun onResume() {
super.onResume()
if (viewModel.isAppUpdateAvailable()) {
if (viewModel.isAppUpdateRequired()) {
showForceUpdateDialog()
Comment on lines +164 to 165

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see the other comment.

}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package org.groundplatform.android.ui.main

import android.net.Uri
import androidx.lifecycle.viewModelScope
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel
Expand All @@ -26,7 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.groundplatform.android.BuildConfig
import org.groundplatform.android.data.remote.UpdateRequiredException
import org.groundplatform.android.di.coroutines.IoDispatcher
import org.groundplatform.android.system.auth.AuthenticationManager
import org.groundplatform.android.system.deeplink.PlayInstallReferrerService
Expand All @@ -37,6 +36,7 @@ import org.groundplatform.domain.model.User
import org.groundplatform.domain.model.auth.SignInState
import org.groundplatform.domain.repository.TermsOfServiceRepositoryInterface
import org.groundplatform.domain.repository.UserRepositoryInterface
import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase
import org.groundplatform.domain.usecases.survey.ReactivateLastSurveyUseCase
import org.groundplatform.domain.usecases.user.ClearUserSessionUseCase
import timber.log.Timber
Expand All @@ -52,7 +52,7 @@ constructor(
private val reactivateLastSurvey: ReactivateLastSurveyUseCase,
private val surveyDeepLinkParser: SurveyDeepLinkParser,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val remoteConfig: FirebaseRemoteConfig,
private val shouldForceUpdateUseCase: ShouldForceUpdateUseCase,
authenticationManager: AuthenticationManager,
private val playInstallReferrerService: PlayInstallReferrerService,
) : AbstractViewModel() {
Expand Down Expand Up @@ -114,6 +114,9 @@ constructor(
}
}
}
} catch (_: UpdateRequiredException) {
// Popup prompting the user to update is displayed, so we don't need to do anything here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of silently consuming the exception, can we instead create a new UiEvent which the ui layer can then listen to for showing the dialog?

return
} catch (e: Throwable) {
Timber.e(e)
// TODO: Display some error dialog to the user with a helpful user-readable message.
Expand All @@ -126,28 +129,6 @@ constructor(
/** Returns true if the user has already accepted the Terms of Service. */
private fun isTosAccepted(): Boolean = termsOfServiceRepository.isTermsOfServiceAccepted

private fun isOlderVersion(current: String, minRequired: String): Boolean {
fun String.toSegments() = split('.').map { it.toIntOrNull() ?: 0 }

val currentParts = current.toSegments()
val requiredParts = minRequired.toSegments()
val maxLength = maxOf(currentParts.size, requiredParts.size)

for (i in 0 until maxLength) {
val curr = currentParts.getOrElse(i) { 0 }
val req = requiredParts.getOrElse(i) { 0 }
if (curr != req) return curr < req
}

return false
}

fun isAppUpdateAvailable(currentVersion: String = BuildConfig.VERSION_NAME): Boolean {
val forceUpdate = remoteConfig.getBoolean("force_update")
val latestVersion = remoteConfig.getString("min_app_version")

return forceUpdate &&
latestVersion.isNotBlank() &&
isOlderVersion(currentVersion, latestVersion)
}
/** Returns true if this build must be updated before it may be used. */
fun isAppUpdateRequired(): Boolean = shouldForceUpdateUseCase()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.remote.firebase

import kotlin.test.assertFailsWith
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.groundplatform.android.FakeData
import org.groundplatform.android.data.remote.UpdateRequiredException
import org.groundplatform.domain.model.AppConfig
import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase
import org.groundplatform.testing.FakeAppConfigRepository
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.verifyNoInteractions
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class FirestoreDataStoreTest {

private val firestoreProvider: FirebaseFirestoreProvider = mock()
private val appConfigRepository =
FakeAppConfigRepository().apply {
config = AppConfig(minAppVersion = "2.0.0", forceUpdate = true)
}
private val dataStore =
FirestoreDataStore(
firebaseFunctions = mock(),
firestoreProvider = firestoreProvider,
shouldForceUpdate = ShouldForceUpdateUseCase(appConfigRepository, currentVersion = "1.0.0"),
ioDispatcher = UnconfinedTestDispatcher(),
)

@Test
fun `Refuses one-off reads when an app update is required`() = runTest {
assertFailsWith<UpdateRequiredException> { dataStore.loadSurvey("surveyId") }
verifyNoInteractions(firestoreProvider)
}

@Test
fun `Refuses listeners when an app update is required`() = runTest {
assertFailsWith<UpdateRequiredException> { dataStore.getPublicSurveyList().first() }
verifyNoInteractions(firestoreProvider)
}

@Test
fun `Refuses uploads when an app update is required`() = runTest {
assertFailsWith<UpdateRequiredException> {
dataStore.applyMutations(emptyList(), FakeData.USER)
}
verifyNoInteractions(firestoreProvider)
}
}
Loading