diff --git a/app/build.gradle b/app/build.gradle index e91a09557f..360629a96e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -239,6 +239,7 @@ dependencies { implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging-directboot' implementation 'com.google.firebase:firebase-messaging-ktx' + implementation 'com.google.firebase:firebase-config' // Hilt implementation "com.google.dagger:hilt-android:$project.hiltVersion" diff --git a/app/src/main/java/org/groundplatform/android/GroundApplication.kt b/app/src/main/java/org/groundplatform/android/GroundApplication.kt index bce2c96944..1ffe94e5d6 100644 --- a/app/src/main/java/org/groundplatform/android/GroundApplication.kt +++ b/app/src/main/java/org/groundplatform/android/GroundApplication.kt @@ -23,6 +23,7 @@ import androidx.core.os.LocaleListCompat import androidx.hilt.work.HiltWorkerFactory import androidx.multidex.MultiDexApplication import androidx.work.Configuration +import com.google.firebase.remoteconfig.FirebaseRemoteConfig import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject import org.groundplatform.android.Config.isReleaseBuild @@ -35,6 +36,7 @@ class GroundApplication : MultiDexApplication(), Configuration.Provider { @Inject lateinit var crashReportingTree: CrashReportingTree @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var localValueStore: LocalValueStore + @Inject lateinit var remoteConfig: FirebaseRemoteConfig override val workManagerConfiguration: Configuration get() = Configuration.Builder().setWorkerFactory(workerFactory).build() @@ -51,6 +53,7 @@ class GroundApplication : MultiDexApplication(), Configuration.Provider { val selectedLanguage = localValueStore.selectedLanguage val appLocale = LocaleListCompat.forLanguageTags(selectedLanguage) AppCompatDelegate.setApplicationLocales(appLocale) + initiateRemoteConfig() } private fun setStrictMode() { @@ -61,6 +64,22 @@ class GroundApplication : MultiDexApplication(), Configuration.Provider { StrictMode.setVmPolicy(VmPolicy.Builder().detectLeakedSqlLiteObjects().penaltyLog().build()) } + /** + * Initializes Firebase Remote Config by setting default values from the provided XML file and + * fetching remote values to activate them for use in the app. + * + * This method: + * - Sets default values using `firebase_remote_config_defaults.xml` + * - Asynchronously fetches the latest Remote Config values from Firebase + * - Immediately activates the fetched values + * + * Call this during app startup to ensure Remote Config values are available. + */ + private fun initiateRemoteConfig() { + remoteConfig.setDefaultsAsync(R.xml.firebase_remote_config_defaults) + remoteConfig.fetchAndActivate() + } + /** Reports any error with priority more than "info" to Crashlytics. */ class CrashReportingTree @Inject diff --git a/app/src/main/java/org/groundplatform/android/GroundApplicationModule.kt b/app/src/main/java/org/groundplatform/android/GroundApplicationModule.kt index c876ecbe2a..b6aa9af0c0 100644 --- a/app/src/main/java/org/groundplatform/android/GroundApplicationModule.kt +++ b/app/src/main/java/org/groundplatform/android/GroundApplicationModule.kt @@ -18,6 +18,11 @@ package org.groundplatform.android import android.content.Context import android.content.res.Resources import com.google.android.gms.common.GoogleApiAvailability +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -43,4 +48,16 @@ object GroundApplicationModule { } @Provides fun provideLocale() = Locale.getDefault() + + @Provides + @Singleton + fun provideFirebaseRemoteConfig(@ApplicationContext context: Context): FirebaseRemoteConfig { + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp(context) + } + + return Firebase.remoteConfig.apply { + setConfigSettingsAsync(remoteConfigSettings { minimumFetchIntervalInSeconds = 3600 }) + } + } } diff --git a/app/src/main/java/org/groundplatform/android/MainActivity.kt b/app/src/main/java/org/groundplatform/android/MainActivity.kt index 9edc52cff4..fca53c5d0e 100644 --- a/app/src/main/java/org/groundplatform/android/MainActivity.kt +++ b/app/src/main/java/org/groundplatform/android/MainActivity.kt @@ -16,6 +16,7 @@ package org.groundplatform.android import android.app.AlertDialog +import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Bundle @@ -197,6 +198,9 @@ class MainActivity : AbstractActivity() { override fun onResume() { super.onResume() viewModel.checkAuthStatus() + if (viewModel.isAppUpdateAvailable()) { + showForceUpdateDialog() + } } /** Override up button behavior to use Navigation Components back stack. */ @@ -261,4 +265,28 @@ class MainActivity : AbstractActivity() { navigate(action) } } + + private fun showForceUpdateDialog() { + AlertDialog.Builder(this) + .setTitle(R.string.dialog_title_update_required) + .setMessage(R.string.dialog_message_update_required) + .setCancelable(false) + .setPositiveButton(R.string.dialog_button_update) { dialog, _ -> + val appPackageName = packageName + try { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$appPackageName")) + ) + } catch (e: ActivityNotFoundException) { + Timber.e("Not able to open play store: $e") + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$appPackageName"), + ) + ) + } + } + .show() + } } diff --git a/app/src/main/java/org/groundplatform/android/MainViewModel.kt b/app/src/main/java/org/groundplatform/android/MainViewModel.kt index e0d0e537fb..7cab5ebf24 100644 --- a/app/src/main/java/org/groundplatform/android/MainViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/MainViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes.SIGN_IN_CANCELLED import com.google.android.gms.common.api.ApiException +import com.google.firebase.remoteconfig.FirebaseRemoteConfig import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableSharedFlow @@ -55,6 +56,7 @@ constructor( private val reactivateLastSurvey: ReactivateLastSurveyUseCase, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val authenticationManager: AuthenticationManager, + private val remoteConfig: FirebaseRemoteConfig, ) : AbstractViewModel() { private val _navigationRequests: MutableSharedFlow = MutableSharedFlow() @@ -138,4 +140,10 @@ constructor( /** Returns true if the user has already accepted the Terms of Service. */ private fun isTosAccepted(): Boolean = termsOfServiceRepository.isTermsOfServiceAccepted + + fun isAppUpdateAvailable(currentVersion: Int = BuildConfig.VERSION_CODE): Boolean { + val forceUpdate = remoteConfig.getBoolean("force_update") + val latestVersion = remoteConfig.getLong("latest_version_code") + return forceUpdate && currentVersion.toLong() < latestVersion + } } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 125f82970c..4aaac7491b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -217,4 +217,8 @@ Francés Español Portugués + + Actualización requerida + Hay una nueva versión de la aplicación disponible. Por favor, actualiza para seguir usando la aplicación. + Actualizar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5c95febc1f..0fe10061e9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -216,4 +216,8 @@ Français Espagnol Portugais + + Mise à jour requise + Une nouvelle version de l’application est disponible. Veuillez mettre à jour pour continuer à utiliser l’application. + Mettre à jour diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a758c73620..22d3eaebf6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -218,4 +218,8 @@ Francês Espanhol Português + + Atualização necessária + Uma nova versão do aplicativo está disponível. Atualize para continuar usando o aplicativo. + Atualizar diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae5e304d7c..ebff32a20f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -217,4 +217,8 @@ French Spanish Portuguese + + Update Required + A new version of the app is available. Please update to continue using the app. + Update diff --git a/app/src/main/res/xml/firebase_remote_config_defaults.xml b/app/src/main/res/xml/firebase_remote_config_defaults.xml new file mode 100644 index 0000000000..a148df13ac --- /dev/null +++ b/app/src/main/res/xml/firebase_remote_config_defaults.xml @@ -0,0 +1,28 @@ + + + + + + + force_update + false + + + latest_version_code + 0 + + diff --git a/app/src/test/java/org/groundplatform/android/MainActivityTest.kt b/app/src/test/java/org/groundplatform/android/MainActivityTest.kt index fb4ea75fce..229d3b4123 100644 --- a/app/src/test/java/org/groundplatform/android/MainActivityTest.kt +++ b/app/src/test/java/org/groundplatform/android/MainActivityTest.kt @@ -47,89 +47,125 @@ class MainActivityTest : BaseHiltTest() { @Test fun signInProgressDialog_whenSigningIn_isDisplayed() = runWithTestDispatcher { - Robolectric.buildActivity(MainActivity::class.java).use { controller -> - controller.setup() // Moves Activity to RESUMED state - activity = controller.get() - - fakeAuthenticationManager.setState(SignInState.SigningIn) - advanceUntilIdle() - - assertThat(ShadowProgressDialog.getLatestDialog().isShowing).isTrue() + try { + Robolectric.buildActivity(MainActivity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + + advanceUntilIdle() + + fakeAuthenticationManager.setState(SignInState.SigningIn) + advanceUntilIdle() + + assertThat(ShadowProgressDialog.getLatestDialog().isShowing).isTrue() + } + } catch (e: Exception) { + println("Caught exception: ${e.message}") + e.printStackTrace() + throw e } } @Test fun signInProgressDialog_whenSignedOutAfterSignInState_isNotDisplayed() = runWithTestDispatcher { - Robolectric.buildActivity(MainActivity::class.java).use { controller -> - controller.setup() // Moves Activity to RESUMED state - activity = controller.get() - - fakeAuthenticationManager.setState(SignInState.SigningIn) - fakeAuthenticationManager.setState(SignInState.SignedOut) - advanceUntilIdle() - - assertThat(ShadowProgressDialog.getLatestDialog().isShowing).isFalse() + try { + Robolectric.buildActivity(MainActivity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + + advanceUntilIdle() + + fakeAuthenticationManager.setState(SignInState.SigningIn) + fakeAuthenticationManager.setState(SignInState.SignedOut) + advanceUntilIdle() + + assertThat(ShadowProgressDialog.getLatestDialog().isShowing).isFalse() + } + } catch (e: Exception) { + println("Caught exception: ${e.message}") + e.printStackTrace() + throw e } } @Test fun signInErrorDialog_isDisplayed() = runWithTestDispatcher { - Robolectric.buildActivity(MainActivity::class.java).use { controller -> - controller.setup() // Moves Activity to RESUMED state - activity = controller.get() - - fakeAuthenticationManager.setState( - SignInState.Error( - error = - FirebaseFirestoreException("error", FirebaseFirestoreException.Code.PERMISSION_DENIED) + try { + Robolectric.buildActivity(MainActivity::class.java).use { controller -> + controller.setup() // Moves Activity to RESUMED state + activity = controller.get() + + fakeAuthenticationManager.setState( + SignInState.Error( + error = + FirebaseFirestoreException("error", FirebaseFirestoreException.Code.PERMISSION_DENIED) + ) ) - ) - advanceUntilIdle() - - assertThat(ShadowProgressDialog.getLatestDialog().isShowing).isTrue() + advanceUntilIdle() + + assertThat(ShadowProgressDialog.getLatestDialog().isShowing).isTrue() + } + } catch (e: Exception) { + println("Caught exception: ${e.message}") + e.printStackTrace() + throw e } } @Test fun launchAppWithSurveyId_loggedInUser_ActivitySurvey() = runWithTestDispatcher { - val uri = Uri.parse("https://groundplatform.org/survey/surveyId") - val intent = Intent(Intent.ACTION_VIEW, uri) - - Robolectric.buildActivity(MainActivity::class.java, intent).use { controller -> - controller.setup() - activity = controller.get() - - val navHost = - activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - val navController = navHost.navController - - fakeAuthenticationManager.setState(SignInState.SignedIn(FakeData.USER)) - advanceUntilIdle() - - assertThat(navController.currentDestination?.id).isEqualTo(R.id.surveySelectorFragment) - - assertThat(navController.currentBackStackEntry?.arguments?.getString("surveyId")) - .isEqualTo("surveyId") + try { + val uri = Uri.parse("https://groundplatform.org/survey/surveyId") + val intent = Intent(Intent.ACTION_VIEW, uri) + + Robolectric.buildActivity(MainActivity::class.java, intent).use { controller -> + controller.setup() + activity = controller.get() + + val navHost = + activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) + as NavHostFragment + val navController = navHost.navController + + fakeAuthenticationManager.setState(SignInState.SignedIn(FakeData.USER)) + advanceUntilIdle() + + assertThat(navController.currentDestination?.id).isEqualTo(R.id.surveySelectorFragment) + + assertThat(navController.currentBackStackEntry?.arguments?.getString("surveyId")) + .isEqualTo("surveyId") + } + } catch (e: Exception) { + println("Caught exception: ${e.message}") + e.printStackTrace() + throw e } } @Test fun launchAppWithSurveyId_needLogin_ShowLoginIn() = runWithTestDispatcher { - val uri = Uri.parse("https://groundplatform.org/survey/surveyId") - val intent = Intent(Intent.ACTION_VIEW, uri) - - Robolectric.buildActivity(MainActivity::class.java, intent).use { controller -> - controller.setup() - activity = controller.get() - - val navHost = - activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - val navController = navHost.navController - - fakeAuthenticationManager.setState(SignInState.SignedOut) - advanceUntilIdle() - - assertThat(navController.currentDestination?.id).isEqualTo(R.id.sign_in_fragment) + try { + val uri = Uri.parse("https://groundplatform.org/survey/surveyId") + val intent = Intent(Intent.ACTION_VIEW, uri) + + Robolectric.buildActivity(MainActivity::class.java, intent).use { controller -> + controller.setup() + activity = controller.get() + + val navHost = + activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) + as NavHostFragment + val navController = navHost.navController + + fakeAuthenticationManager.setState(SignInState.SignedOut) + advanceUntilIdle() + + assertThat(navController.currentDestination?.id).isEqualTo(R.id.sign_in_fragment) + } + } catch (e: Exception) { + println("Caught exception: ${e.message}") + e.printStackTrace() + throw e } } }