From 47856d781eb39965bdb7ffae998fab613b95abc9 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 10:19:44 +0200 Subject: [PATCH 1/8] create repository impl and interface for remote config --- .../android/repository/AppConfigRepository.kt | 33 +++++++++++++++++++ .../groundplatform/domain/model/AppConfig.kt | 21 ++++++++++++ .../AppConfigRepositoryInterface.kt | 23 +++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/AppConfigRepositoryInterface.kt diff --git a/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt b/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt new file mode 100644 index 0000000000..252491fb18 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt @@ -0,0 +1,33 @@ +/* + * 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 + +@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"), + ) +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt new file mode 100644 index 0000000000..888e8d10bf --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt @@ -0,0 +1,21 @@ +/* + * 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 AppConfig( + val minAppVersion: String, + val forceUpdate: Boolean +) \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/AppConfigRepositoryInterface.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/AppConfigRepositoryInterface.kt new file mode 100644 index 0000000000..f6a9c9abe2 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/repository/AppConfigRepositoryInterface.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.repository + +import org.groundplatform.domain.model.AppConfig + +interface AppConfigRepositoryInterface { + /** Returns the currently active config. */ + fun getAppConfig(): AppConfig +} From de776f83e42e9928cd441c9c8133ff9fea48c122 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 10:25:29 +0200 Subject: [PATCH 2/8] add new use case to check for app update and implement it --- .../android/di/RepositoryModule.kt | 10 ++++ .../android/di/UseCaseModule.kt | 9 ++++ .../android/ui/main/MainActivity.kt | 2 +- .../android/ui/main/MainViewModel.kt | 35 +++---------- .../usecases/ShouldForceUpdateUseCase.kt | 49 +++++++++++++++++++ 5 files changed, 77 insertions(+), 28 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt diff --git a/app/src/main/java/org/groundplatform/android/di/RepositoryModule.kt b/app/src/main/java/org/groundplatform/android/di/RepositoryModule.kt index 59225f5fd1..925ebc452b 100644 --- a/app/src/main/java/org/groundplatform/android/di/RepositoryModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/RepositoryModule.kt @@ -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 @@ -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 @@ -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 +} diff --git a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt index f1fea18187..cd8139ddf5 100644 --- a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt @@ -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 @@ -125,4 +127,11 @@ object UseCaseModule { surveyRepository: SurveyRepositoryInterface, userRepository: UserRepositoryInterface, ) = ListAvailableSurveysUseCase(networkManager, surveyRepository, userRepository) + + @Provides + fun providesShouldForceUpdateUseCase(appConfigRepositoryInterface: AppConfigRepositoryInterface) = + org.groundplatform.domain.usecases.ShouldForceUpdateUseCase( + appConfigRepository = appConfigRepositoryInterface, + currentVersion = BuildConfig.VERSION_NAME, + ) } diff --git a/app/src/main/java/org/groundplatform/android/ui/main/MainActivity.kt b/app/src/main/java/org/groundplatform/android/ui/main/MainActivity.kt index 452d7fb099..5470363c6e 100644 --- a/app/src/main/java/org/groundplatform/android/ui/main/MainActivity.kt +++ b/app/src/main/java/org/groundplatform/android/ui/main/MainActivity.kt @@ -161,7 +161,7 @@ class MainActivity : AbstractActivity() { override fun onResume() { super.onResume() - if (viewModel.isAppUpdateAvailable()) { + if (viewModel.isAppUpdateRequired()) { showForceUpdateDialog() } } diff --git a/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt index bbc8624b65..7d1dc8e0bf 100644 --- a/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt @@ -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 @@ -26,7 +25,6 @@ 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.di.coroutines.IoDispatcher import org.groundplatform.android.system.auth.AuthenticationManager import org.groundplatform.android.system.deeplink.PlayInstallReferrerService @@ -37,6 +35,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 @@ -52,7 +51,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() { @@ -92,6 +91,10 @@ constructor( } private suspend fun onUserSignedIn(user: User) { + // Stay behind the update dialog. The steps below may call the remote store, and any failure + // among them signs the user out, which clears the local db along with unsynced changes. + if (shouldForceUpdateUseCase()) return + val destination = try { userRepository.saveUserDetails(user) @@ -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() } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt new file mode 100644 index 0000000000..9ed67c4ce7 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt @@ -0,0 +1,49 @@ +/* + * 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.usecases + +import org.groundplatform.domain.repository.AppConfigRepositoryInterface + +class ShouldForceUpdateUseCase( + private val appConfigRepository: AppConfigRepositoryInterface, + private val currentVersion: String, +) { + + /** Returns true if this build must be updated before it may be used. */ + operator fun invoke(): Boolean { + val appConfig = appConfigRepository.getAppConfig() + val minRequired = appConfig.minAppVersion + val forceUpdate = appConfig.forceUpdate + + return forceUpdate && minRequired.isNotBlank() && isOlderVersion(currentVersion, minRequired) + } + + 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 + } +} From 139d5bc6f03e419bd9562c94ea5f4287536b0c2f Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 16:59:25 +0200 Subject: [PATCH 3/8] add shouldForceUpdate check in all workers --- .../android/data/remote/DataStoreException.kt | 2 ++ .../android/data/remote/firebase/FirestoreDataStore.kt | 8 +++++++- .../android/data/sync/LocalMutationSyncWorker.kt | 5 +++++ .../groundplatform/android/data/sync/MediaUploadWorker.kt | 5 +++++ .../groundplatform/android/data/sync/SurveySyncWorker.kt | 5 +++++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/data/remote/DataStoreException.kt b/app/src/main/java/org/groundplatform/android/data/remote/DataStoreException.kt index 4a4c2c624c..59acf0f5ad 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/DataStoreException.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/DataStoreException.kt @@ -41,3 +41,5 @@ open class DataStoreException(message: String?) : RuntimeException(message) { } } } + +class UpdateRequiredException : DataStoreException("App update required") diff --git a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt index 2af652440a..8806c7a1a1 100644 --- a/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStore.kt @@ -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 @@ -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" @@ -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() } diff --git a/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt index bb09f15c2b..98ad50efee 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt @@ -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 /** @@ -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) { + // Changes stay queued locally and are uploaded once the updated app is opened. + if (shouldForceUpdate()) return@withContext success() + val queue = mutationRepository.getIncompleteUploads() Timber.d("Uploading ${queue.size} additions / changes") val results = queue.map { mutationRepository.processMutations(it.mutations()) } diff --git a/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt index 412038e823..52b043b099 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt @@ -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 /** @@ -54,11 +55,15 @@ 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) { + // Media stays queued locally and is uploaded once the updated app is opened. + if (shouldForceUpdate()) return@withContext success() + val mutations = mutationRepository.getIncompleteMediaMutations() Timber.d("Uploading photos for ${mutations.size} submission mutations") val results = mutations.map { uploadAllMedia(it) } diff --git a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt index 9b4370c7d0..617aace091 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/SurveySyncWorker.kt @@ -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 @@ -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) @@ -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() From ab178b6e9b03738851047a33e0bbb7c7958de0ee Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 17:07:52 +0200 Subject: [PATCH 4/8] add unit tests --- .../remote/firebase/FirestoreDataStoreTest.kt | 68 +++++++++++++++ .../data/sync/LocalMutationSyncWorkerTest.kt | 19 +++++ .../data/sync/MediaUploadWorkerTest.kt | 23 ++++++ .../data/sync/SurveySyncServiceTest.kt | 6 ++ .../android/data/sync/SurveySyncWorkerTest.kt | 33 +++++++- .../repository/AppConfigRepositoryTest.kt | 52 ++++++++++++ .../android/ui/main/MainActivityTest.kt | 73 +++++++++++++++++ .../android/ui/main/MainViewModelTest.kt | 33 ++++++++ .../groundplatform/domain/model/AppConfig.kt | 6 +- .../usecases/ShouldForceUpdateUseCaseTest.kt | 82 +++++++++++++++++++ 10 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/repository/AppConfigRepositoryTest.kt create mode 100644 core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCaseTest.kt diff --git a/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt new file mode 100644 index 0000000000..42f402d403 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/data/remote/firebase/FirestoreDataStoreTest.kt @@ -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 { dataStore.loadSurvey("surveyId") } + verifyNoInteractions(firestoreProvider) + } + + @Test + fun `Refuses listeners when an app update is required`() = runTest { + assertFailsWith { dataStore.getPublicSurveyList().first() } + verifyNoInteractions(firestoreProvider) + } + + @Test + fun `Refuses uploads when an app update is required`() = runTest { + assertFailsWith { + dataStore.applyMutations(emptyList(), FakeData.USER) + } + verifyNoInteractions(firestoreProvider) + } +} diff --git a/app/src/test/java/org/groundplatform/android/data/sync/LocalMutationSyncWorkerTest.kt b/app/src/test/java/org/groundplatform/android/data/sync/LocalMutationSyncWorkerTest.kt index 1a08d5ea3a..1605fa7163 100644 --- a/app/src/test/java/org/groundplatform/android/data/sync/LocalMutationSyncWorkerTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/sync/LocalMutationSyncWorkerTest.kt @@ -39,6 +39,7 @@ import org.groundplatform.android.data.local.stores.LocalUserStore import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.di.coroutines.IoDispatcher import org.groundplatform.android.system.auth.FakeAuthenticationManager +import org.groundplatform.domain.model.AppConfig import org.groundplatform.domain.model.geometry.Point import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.mutation.Mutation.SyncStatus.COMPLETED @@ -52,6 +53,8 @@ import org.groundplatform.domain.model.task.PhotoTaskData import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.repository.MutationRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface +import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase +import org.groundplatform.testing.FakeAppConfigRepository import org.groundplatform.testing.FakeDataGenerator import org.junit.Before import org.junit.Test @@ -87,6 +90,10 @@ class LocalMutationSyncWorkerTest : BaseHiltTest() { @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher + private val appConfigRepository = FakeAppConfigRepository() + private val shouldForceUpdate = + ShouldForceUpdateUseCase(appConfigRepository, currentVersion = "1.0.0") + private val factory = object : WorkerFactory() { override fun createWorker( @@ -99,6 +106,7 @@ class LocalMutationSyncWorkerTest : BaseHiltTest() { workerParameters, mutationRepository, mockMediaUploadWorkManager, + shouldForceUpdate, ioDispatcher, ) } @@ -157,6 +165,17 @@ class LocalMutationSyncWorkerTest : BaseHiltTest() { assertMutationsState(complete = 2) } + @Test + fun `Leaves mutations queued when an app update is required`() = runWithTestDispatcher { + appConfigRepository.config = AppConfig(minAppVersion = "2.0.0", forceUpdate = true) + addPendingMutations() + + val result = createAndDoWork(context) + + assertThat(result).isEqualTo(success()) + assertMutationsState(pending = 2) + } + @Test fun `Retries if there are non-zero pending mutations but remote sync fails`() = runWithTestDispatcher { diff --git a/app/src/test/java/org/groundplatform/android/data/sync/MediaUploadWorkerTest.kt b/app/src/test/java/org/groundplatform/android/data/sync/MediaUploadWorkerTest.kt index a5c9ddd8a9..85bedde83d 100644 --- a/app/src/test/java/org/groundplatform/android/data/sync/MediaUploadWorkerTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/sync/MediaUploadWorkerTest.kt @@ -36,6 +36,7 @@ import org.groundplatform.android.data.local.stores.LocalUserStore import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.data.remote.FakeRemoteStorageManager import org.groundplatform.android.di.coroutines.IoDispatcher +import org.groundplatform.domain.model.AppConfig import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.mutation.Mutation.SyncStatus.COMPLETED import org.groundplatform.domain.model.mutation.Mutation.SyncStatus.FAILED @@ -52,6 +53,8 @@ import org.groundplatform.domain.model.task.Task import org.groundplatform.domain.model.task.Task.Type.PHOTO import org.groundplatform.domain.repository.MutationRepositoryInterface import org.groundplatform.domain.repository.UserMediaRepositoryInterface +import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase +import org.groundplatform.testing.FakeAppConfigRepository import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -71,6 +74,10 @@ class MediaUploadWorkerTest : BaseHiltTest() { @Inject lateinit var localLocationOfInterestStore: LocalLocationOfInterestStore @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher + private val appConfigRepository = FakeAppConfigRepository() + private val shouldForceUpdate = + ShouldForceUpdateUseCase(appConfigRepository, currentVersion = "1.0.0") + private val factory = object : WorkerFactory() { override fun createWorker( @@ -84,6 +91,7 @@ class MediaUploadWorkerTest : BaseHiltTest() { fakeRemoteStorageManager, mutationRepository, userMediaRepository, + shouldForceUpdate, ioDispatcher, ) } @@ -167,6 +175,21 @@ class MediaUploadWorkerTest : BaseHiltTest() { assertThatMutationCountEquals(MEDIA_UPLOAD_IN_PROGRESS, 0) } + @Test + fun `doWork leaves media queued when an app update is required`() = runWithTestDispatcher { + appConfigRepository.config = AppConfig(minAppVersion = "2.0.0", forceUpdate = true) + localUserStore.insertOrUpdateUser(FakeData.USER) + localSurveyStore.insertOrUpdateSurvey(TEST_SURVEY) + localLocationOfInterestStore.insertOrUpdate(TEST_LOI) + localSubmissionStore.applyAndEnqueue( + createSubmissionMutation().copy(syncStatus = MEDIA_UPLOAD_PENDING) + ) + + createAndDoWork(context) + + assertThatMutationCountEquals(MEDIA_UPLOAD_PENDING, 1) + } + // Initiates and runs the MediaUploadWorker private suspend fun createAndDoWork(context: Context) { TestListenableWorkerBuilder(context) diff --git a/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncServiceTest.kt b/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncServiceTest.kt index f597d37358..5c96ddd8e1 100644 --- a/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncServiceTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncServiceTest.kt @@ -39,7 +39,9 @@ import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.SURVEY 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 org.groundplatform.testing.FakeAppConfigRepository import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -61,6 +63,9 @@ class SurveySyncServiceTest : BaseHiltTest() { private lateinit var workManager: WorkManager private lateinit var testDriver: TestDriver + private val shouldForceUpdateUseCase = + ShouldForceUpdateUseCase(FakeAppConfigRepository(), currentVersion = "1.0.0") + @Before override fun setUp() { super.setUp() @@ -81,6 +86,7 @@ class SurveySyncServiceTest : BaseHiltTest() { workerParameters, syncSurvey, surveyRepository, + shouldForceUpdateUseCase, ioDispatcher, ) } diff --git a/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncWorkerTest.kt b/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncWorkerTest.kt index c2d53285ef..2757381270 100644 --- a/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncWorkerTest.kt +++ b/app/src/test/java/org/groundplatform/android/data/sync/SurveySyncWorkerTest.kt @@ -40,8 +40,11 @@ import org.groundplatform.android.data.sync.SurveySyncWorker.Companion.MAX_SYNC_ import org.groundplatform.android.data.sync.SurveySyncWorker.Companion.SURVEY_ID_PARAM_KEY import org.groundplatform.android.data.sync.SurveySyncWorker.Companion.SYNC_TIMEOUT_MILLIS import org.groundplatform.android.di.coroutines.IoDispatcher +import org.groundplatform.domain.model.AppConfig import org.groundplatform.domain.repository.SurveyRepositoryInterface +import org.groundplatform.domain.usecases.ShouldForceUpdateUseCase import org.groundplatform.domain.usecases.survey.SyncSurveyUseCase +import org.groundplatform.testing.FakeAppConfigRepository import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -63,13 +66,25 @@ class SurveySyncWorkerTest : BaseHiltTest() { @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher + private val appConfigRepository = FakeAppConfigRepository() + private val shouldForceUpdate = + ShouldForceUpdateUseCase(appConfigRepository, currentVersion = "1.0.0") + private val factory = object : WorkerFactory() { override fun createWorker( appContext: Context, workerClassName: String, workerParameters: WorkerParameters, - ) = SurveySyncWorker(appContext, workerParameters, syncSurvey, surveyRepository, ioDispatcher) + ) = + SurveySyncWorker( + appContext, + workerParameters, + syncSurvey, + surveyRepository, + shouldForceUpdate, + ioDispatcher, + ) } @Before @@ -108,6 +123,22 @@ class SurveySyncWorkerTest : BaseHiltTest() { verify(syncSurvey).invoke(SURVEY.id) } + @Test + fun `doWork() skips the sync when an app update is required`() = runWithTestDispatcher { + appConfigRepository.config = AppConfig(minAppVersion = "2.0.0", forceUpdate = true) + + val worker = + TestListenableWorkerBuilder( + context, + inputData = workDataOf(Pair(SURVEY_ID_PARAM_KEY, SURVEY.id)), + ) + .setWorkerFactory(factory) + .build() + val result = worker.doWork() + assertThat(result).isEqualTo(Result.success()) + verifyBlocking(syncSurvey, never()) { invoke(SURVEY.id) } + } + @Test fun `doWork() retries on failure`() = runWithTestDispatcher { `when`(surveyRepository.getOfflineSurvey(SURVEY.id)).thenReturn(SURVEY) diff --git a/app/src/test/java/org/groundplatform/android/repository/AppConfigRepositoryTest.kt b/app/src/test/java/org/groundplatform/android/repository/AppConfigRepositoryTest.kt new file mode 100644 index 0000000000..c655bfb1b3 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/repository/AppConfigRepositoryTest.kt @@ -0,0 +1,52 @@ +/* + * 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.common.truth.Truth.assertThat +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import org.groundplatform.domain.model.AppConfig +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AppConfigRepositoryTest { + + private val remoteConfig: FirebaseRemoteConfig = mock { + on { getString("min_app_version") } doReturn "1.0.0" + on { getBoolean("force_update") } doReturn false + } + private val repository = AppConfigRepository(remoteConfig) + + @Test + fun `Returns the active config`() { + assertThat(repository.getAppConfig()) + .isEqualTo(AppConfig(minAppVersion = "1.0.0", forceUpdate = false)) + } + + @Test + fun `Returns newly activated values on the next call`() { + repository.getAppConfig() + whenever(remoteConfig.getString("min_app_version")).thenReturn("2.0.0") + whenever(remoteConfig.getBoolean("force_update")).thenReturn(true) + + assertThat(repository.getAppConfig()) + .isEqualTo(AppConfig(minAppVersion = "2.0.0", forceUpdate = true)) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/main/MainActivityTest.kt b/app/src/test/java/org/groundplatform/android/ui/main/MainActivityTest.kt index a4a9f25dc8..a5b34cdff1 100644 --- a/app/src/test/java/org/groundplatform/android/ui/main/MainActivityTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/main/MainActivityTest.kt @@ -15,9 +15,11 @@ */ package org.groundplatform.android.ui.main +import android.app.AlertDialog import android.content.Intent import android.net.Uri import android.os.Bundle +import android.os.Looper import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment @@ -25,27 +27,36 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.remoteconfig.FirebaseRemoteConfig import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData import org.groundplatform.android.R +import org.groundplatform.android.di.AppConfigRepositoryModule import org.groundplatform.android.getString import org.groundplatform.android.system.auth.FakeAuthenticationManager import org.groundplatform.android.system.deeplink.PlayInstallReferrerService +import org.groundplatform.domain.model.AppConfig import org.groundplatform.domain.model.auth.SignInState +import org.groundplatform.domain.repository.AppConfigRepositoryInterface import org.groundplatform.domain.repository.TermsOfServiceRepositoryInterface +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest +@UninstallModules(AppConfigRepositoryModule::class) @RunWith(RobolectricTestRunner::class) class MainActivityTest : BaseHiltTest() { @get:Rule val composeTestRule = createComposeRule() @@ -58,6 +69,13 @@ class MainActivityTest : BaseHiltTest() { @Inject lateinit var remoteConfig: FirebaseRemoteConfig @BindValue @JvmField val playInstallReferrerService: PlayInstallReferrerService = mock() + @BindValue @Mock lateinit var appConfigRepository: AppConfigRepositoryInterface + + @Before + override fun setUp() { + super.setUp() + whenever(appConfigRepository.getAppConfig()).thenReturn(NO_UPDATE) + } @Test fun `Launch app with survey ID navigates to survey selector when user is logged in`() = @@ -193,7 +211,62 @@ class MainActivityTest : BaseHiltTest() { } } + @Test + fun `Update dialog opens the Play Store`() = runWithTestDispatcher { + whenever(appConfigRepository.getAppConfig()).thenReturn(UPDATE_REQUIRED) + + Robolectric.buildActivity(MainActivity::class.java).use { controller -> + controller.setup() + advanceUntilIdle() + val dialog = checkNotNull(updateDialog()) { "Update dialog not shown" } + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + // The dialog delivers button clicks through the main looper. + shadowOf(Looper.getMainLooper()).idle() + + assertThat(shadowOf(controller.get()).nextStartedActivity.data) + .isEqualTo(Uri.parse("market://details?id=${controller.get().packageName}")) + } + } + + @Test + fun `Update dialog shows again when returning to the app`() = runWithTestDispatcher { + whenever(appConfigRepository.getAppConfig()).thenReturn(UPDATE_REQUIRED) + + Robolectric.buildActivity(MainActivity::class.java).use { controller -> + controller.setup() + checkNotNull(updateDialog()).getButton(AlertDialog.BUTTON_POSITIVE).performClick() + shadowOf(Looper.getMainLooper()).idle() + assertThat(updateDialog()).isNull() + + controller.pause().resume() + + assertThat(updateDialog()).isNotNull() + } + } + + @Test + fun `No update dialog when no update is required`() = runWithTestDispatcher { + Robolectric.buildActivity(MainActivity::class.java).use { controller -> + controller.setup() + advanceUntilIdle() + + assertThat(updateDialog()).isNull() + } + } + + private fun updateDialog(): AlertDialog? = + ShadowDialog.getShownDialogs().filterIsInstance().firstOrNull { + it.isShowing && + shadowOf(it).title?.toString() == getString(R.string.dialog_title_update_required) + } + private fun MainActivity.navController(): NavController = (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment) .navController + + companion object { + private val NO_UPDATE = AppConfig(minAppVersion = "0.0.0", forceUpdate = false) + private val UPDATE_REQUIRED = AppConfig(minAppVersion = "9999.0.0", forceUpdate = true) + } } diff --git a/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt index 39bbf42aac..9d25bcb889 100644 --- a/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt @@ -22,6 +22,7 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.firestore.FirebaseFirestoreException import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules import javax.inject.Inject import kotlin.test.assertFailsWith import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -30,24 +31,30 @@ import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData import org.groundplatform.android.data.local.room.LocalDataStoreException import org.groundplatform.android.data.remote.FakeRemoteDataStore +import org.groundplatform.android.di.AppConfigRepositoryModule import org.groundplatform.android.system.auth.FakeAuthenticationManager import org.groundplatform.android.system.deeplink.PlayInstallReferrerService +import org.groundplatform.domain.model.AppConfig import org.groundplatform.domain.model.auth.SignInState +import org.groundplatform.domain.repository.AppConfigRepositoryInterface import org.groundplatform.domain.repository.TermsOfServiceRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest +@UninstallModules(AppConfigRepositoryModule::class) @RunWith(RobolectricTestRunner::class) class MainViewModelTest : BaseHiltTest() { @BindValue @JvmField val playInstallReferrerService: PlayInstallReferrerService = mock() + @BindValue @Mock lateinit var appConfigRepository: AppConfigRepositoryInterface @Inject lateinit var fakeAuthenticationManager: FakeAuthenticationManager @Inject lateinit var fakeRemoteDataStore: FakeRemoteDataStore @@ -61,6 +68,7 @@ class MainViewModelTest : BaseHiltTest() { super.setUp() fakeAuthenticationManager.setUser(FakeData.USER) + whenever(appConfigRepository.getAppConfig()).thenReturn(NO_UPDATE) } private fun setupUserPreferences() { @@ -216,7 +224,32 @@ class MainViewModelTest : BaseHiltTest() { } } + @Test + fun `navigation is skipped when an app update is required`() = runWithTestDispatcher { + tosRepository.isTermsOfServiceAccepted = true + whenever(appConfigRepository.getAppConfig()).thenReturn(UPDATE_REQUIRED) + + viewModel.uiEffects.test { + fakeAuthenticationManager.signIn() + advanceUntilIdle() + + expectNoEvents() + verifyUserNotSaved() + } + } + + @Test + fun `isAppUpdateRequired reflects the active config`() { + assertThat(viewModel.isAppUpdateRequired()).isFalse() + + whenever(appConfigRepository.getAppConfig()).thenReturn(UPDATE_REQUIRED) + + assertThat(viewModel.isAppUpdateRequired()).isTrue() + } + companion object { private const val SURVEY_ID = "survey_123" + private val NO_UPDATE = AppConfig(minAppVersion = "0.0.0", forceUpdate = false) + private val UPDATE_REQUIRED = AppConfig(minAppVersion = "9999.0.0", forceUpdate = true) } } diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt index 888e8d10bf..a19771f2d4 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt @@ -16,6 +16,6 @@ package org.groundplatform.domain.model data class AppConfig( - val minAppVersion: String, - val forceUpdate: Boolean -) \ No newline at end of file + val minAppVersion: String, + val forceUpdate: Boolean, +) diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCaseTest.kt new file mode 100644 index 0000000000..544ffcb361 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCaseTest.kt @@ -0,0 +1,82 @@ +/* + * 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.usecases + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.groundplatform.domain.model.AppConfig +import org.groundplatform.testing.FakeAppConfigRepository + +class ShouldForceUpdateUseCaseTest { + private val appConfigRepository = FakeAppConfigRepository() + private val useCase = ShouldForceUpdateUseCase(appConfigRepository, currentVersion = "1.2.3") + + private fun setConfig(minAppVersion: String, forceUpdate: Boolean) { + appConfigRepository.config = AppConfig(minAppVersion, forceUpdate) + } + + @Test + fun `Requires update when forced and app is older than the min version`() { + setConfig(minAppVersion = "1.3.0", forceUpdate = true) + + assertTrue(useCase()) + } + + @Test + fun `Does not require update when not forced`() { + setConfig(minAppVersion = "1.3.0", forceUpdate = false) + + assertFalse(useCase()) + } + + @Test + fun `Does not require update when app is at the min version`() { + setConfig(minAppVersion = "1.2.3", forceUpdate = true) + + assertFalse(useCase()) + } + + @Test + fun `Does not require update when app is newer than the min version`() { + setConfig(minAppVersion = "1.2.0", forceUpdate = true) + + assertFalse(useCase()) + } + + @Test + fun `Does not require update when min version is blank`() { + setConfig(minAppVersion = "", forceUpdate = true) + + assertFalse(useCase()) + } + + @Test + fun `Compares version segments numerically`() { + setConfig(minAppVersion = "1.10.0", forceUpdate = true) + + assertTrue(useCase()) + } + + @Test + fun `Reads the config again on each call`() { + assertFalse(useCase()) + + setConfig(minAppVersion = "1.3.0", forceUpdate = true) + + assertTrue(useCase()) + } +} From 89790bbd835efc8b40134fdd5cdfce3cd3f52230 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 17:49:11 +0200 Subject: [PATCH 5/8] handle UpdateRequiredException to avoid signing out user and clearing data in case requests start being blocked --- .../groundplatform/android/ui/main/MainViewModel.kt | 8 ++++---- .../android/ui/main/MainViewModelTest.kt | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt index 7d1dc8e0bf..91150fa986 100644 --- a/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/main/MainViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +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 @@ -91,10 +92,6 @@ constructor( } private suspend fun onUserSignedIn(user: User) { - // Stay behind the update dialog. The steps below may call the remote store, and any failure - // among them signs the user out, which clears the local db along with unsynced changes. - if (shouldForceUpdateUseCase()) return - val destination = try { userRepository.saveUserDetails(user) @@ -117,6 +114,9 @@ constructor( } } } + } catch (_: UpdateRequiredException) { + // Popup prompting the user to update is displayed, so we don't need to do anything here. + return } catch (e: Throwable) { Timber.e(e) // TODO: Display some error dialog to the user with a helpful user-readable message. diff --git a/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt index 9d25bcb889..6b9bea39f3 100644 --- a/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/main/MainViewModelTest.kt @@ -29,8 +29,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData +import org.groundplatform.android.data.local.LocalValueStore import org.groundplatform.android.data.local.room.LocalDataStoreException import org.groundplatform.android.data.remote.FakeRemoteDataStore +import org.groundplatform.android.data.remote.UpdateRequiredException import org.groundplatform.android.di.AppConfigRepositoryModule import org.groundplatform.android.system.auth.FakeAuthenticationManager import org.groundplatform.android.system.deeplink.PlayInstallReferrerService @@ -58,6 +60,7 @@ class MainViewModelTest : BaseHiltTest() { @Inject lateinit var fakeAuthenticationManager: FakeAuthenticationManager @Inject lateinit var fakeRemoteDataStore: FakeRemoteDataStore + @Inject lateinit var localValueStore: LocalValueStore @Inject lateinit var viewModel: MainViewModel @Inject lateinit var sharedPreferences: SharedPreferences @Inject lateinit var tosRepository: TermsOfServiceRepositoryInterface @@ -225,16 +228,17 @@ class MainViewModelTest : BaseHiltTest() { } @Test - fun `navigation is skipped when an app update is required`() = runWithTestDispatcher { + fun `stays signed in when an app update is required`() = runWithTestDispatcher { tosRepository.isTermsOfServiceAccepted = true - whenever(appConfigRepository.getAppConfig()).thenReturn(UPDATE_REQUIRED) + localValueStore.lastActiveSurveyId = SURVEY_ID + fakeRemoteDataStore.onLoadSurvey = { throw UpdateRequiredException() } viewModel.uiEffects.test { fakeAuthenticationManager.signIn() advanceUntilIdle() expectNoEvents() - verifyUserNotSaved() + verifyUserSaved() } } From c70ad3449e5e87b04766588d0deafaae658cc14f Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 17:52:45 +0200 Subject: [PATCH 6/8] fix detekt issues --- .../android/data/sync/LocalMutationSyncWorker.kt | 9 +++++---- .../android/data/sync/MediaUploadWorker.kt | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt index 98ad50efee..9818b7c3e9 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/LocalMutationSyncWorker.kt @@ -46,11 +46,11 @@ constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result = - withContext(ioDispatcher) { - // Changes stay queued locally and are uploaded once the updated app is opened. - if (shouldForceUpdate()) return@withContext success() + 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()) } @@ -64,4 +64,5 @@ constructor( if (results.size == successfulMutations.size) success() else retry() } + } } diff --git a/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt b/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt index 52b043b099..f993940473 100644 --- a/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt +++ b/app/src/main/java/org/groundplatform/android/data/sync/MediaUploadWorker.kt @@ -59,16 +59,17 @@ constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(context, workerParams) { - override suspend fun doWork(): Result = - withContext(ioDispatcher) { - // Media stays queued locally and is uploaded once the updated app is opened. - if (shouldForceUpdate()) return@withContext success() + 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 From b75c9791bf677f43d08299eb2d82730ec6753ea0 Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 18:07:29 +0200 Subject: [PATCH 7/8] add kdoc to AppConfig, AppConfigRepository and ShouldForceUpdateUseCase --- .../groundplatform/android/repository/AppConfigRepository.kt | 1 + .../kotlin/org/groundplatform/domain/model/AppConfig.kt | 1 + .../groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt b/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt index 252491fb18..41f02fb3d6 100644 --- a/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt +++ b/app/src/main/java/org/groundplatform/android/repository/AppConfigRepository.kt @@ -21,6 +21,7 @@ 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 { diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt index a19771f2d4..3d07fe3f8a 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/AppConfig.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.domain.model +/** Remotely managed settings that control whether this app version must be updated. */ data class AppConfig( val minAppVersion: String, val forceUpdate: Boolean, diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt index 9ed67c4ce7..91c1db604e 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/ShouldForceUpdateUseCase.kt @@ -17,12 +17,12 @@ package org.groundplatform.domain.usecases import org.groundplatform.domain.repository.AppConfigRepositoryInterface +/** Checks whether this build is below the minimum version required by the app config. */ class ShouldForceUpdateUseCase( private val appConfigRepository: AppConfigRepositoryInterface, private val currentVersion: String, ) { - /** Returns true if this build must be updated before it may be used. */ operator fun invoke(): Boolean { val appConfig = appConfigRepository.getAppConfig() val minRequired = appConfig.minAppVersion From 3260492beec429d56744a6dcf7060c9745b4b48e Mon Sep 17 00:00:00 2001 From: andreia Date: Fri, 11 Sep 2026 18:08:24 +0200 Subject: [PATCH 8/8] add missing FakeAppConfigRepository --- .../testing/FakeAppConfigRepository.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeAppConfigRepository.kt diff --git a/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeAppConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeAppConfigRepository.kt new file mode 100644 index 0000000000..5e1e9555a1 --- /dev/null +++ b/core/testing/src/commonMain/kotlin/org/groundplatform/testing/FakeAppConfigRepository.kt @@ -0,0 +1,25 @@ +/* + * 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.testing + +import org.groundplatform.domain.model.AppConfig +import org.groundplatform.domain.repository.AppConfigRepositoryInterface + +class FakeAppConfigRepository : AppConfigRepositoryInterface { + var config = AppConfig(minAppVersion = "", forceUpdate = false) + + override fun getAppConfig(): AppConfig = config +}