diff --git a/app/build.gradle b/app/build.gradle index d59fd2ec5e..59e1ee07c8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -346,6 +346,9 @@ dependencies { // Pulls protodefs from the specified commit in the ground-platform repo. groundProtoJar libs.ground.platform + + // Deferred deeplinks + implementation libs.android.install.referrer } protobuf { diff --git a/app/src/main/java/org/groundplatform/android/common/PrefKeys.kt b/app/src/main/java/org/groundplatform/android/common/PrefKeys.kt index b29c8306e1..fa2c77166c 100644 --- a/app/src/main/java/org/groundplatform/android/common/PrefKeys.kt +++ b/app/src/main/java/org/groundplatform/android/common/PrefKeys.kt @@ -32,4 +32,5 @@ object PrefKeys { const val TOS_ACCEPTED = "tos_accepted" const val UPLOAD_MEDIA = "upload_media" const val VISIT_WEBSITE = "visit_website" + const val DEFERRED_DEEPLINK_CONSUMED = "deferred_deeplink_consumed" } diff --git a/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt b/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt index 4461451061..0d26118aaa 100644 --- a/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt +++ b/app/src/main/java/org/groundplatform/android/data/local/LocalValueStore.kt @@ -123,6 +123,14 @@ constructor(private val preferences: SharedPreferences, private val locale: Loca preferences.edit { putBoolean(PrefKeys.UPLOAD_MEDIA, value) } } + var isDeferredDeeplinkConsumed: Boolean + get() = allowThreadDiskReads { + preferences.getBoolean(PrefKeys.DEFERRED_DEEPLINK_CONSUMED, false) + } + set(value) = allowThreadDiskWrites { + preferences.edit { putBoolean(PrefKeys.DEFERRED_DEEPLINK_CONSUMED, value) } + } + /** Removes all values stored in the local store. */ fun clear() = allowThreadDiskWrites { preferences.edit { clear() } } diff --git a/app/src/main/java/org/groundplatform/android/system/deeplink/InstallReferrerManager.kt b/app/src/main/java/org/groundplatform/android/system/deeplink/InstallReferrerManager.kt new file mode 100644 index 0000000000..f126e0d132 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/system/deeplink/InstallReferrerManager.kt @@ -0,0 +1,105 @@ +/* + * 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.system.deeplink + +import android.content.Context +import android.net.Uri +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerStateListener +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import org.groundplatform.android.data.local.LocalValueStore +import timber.log.Timber + +@Singleton +class InstallReferrerManager +@Inject +constructor( + @ApplicationContext private val context: Context, + private val localValueStore: LocalValueStore, +) { + suspend fun getDeferredSurveyId(): String? { + if (localValueStore.isDeferredDeeplinkConsumed) return null + return when (val result = queryInstallReferrer()) { + is InstallReferrerResult.Success -> { + if (!result.referrer.isEmpty()) { + localValueStore.isDeferredDeeplinkConsumed = true + parseSurveyId(Uri.decode(result.referrer)) + } else null + } + InstallReferrerResult.Unavailable -> null + } + } + + private suspend fun queryInstallReferrer(): InstallReferrerResult = suspendCancellableCoroutine { + val client = InstallReferrerClient.newBuilder(context).build() + val listener = + object : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(responseCode: Int) { + val result = readReferrer(client, responseCode) + client.endConnection() + it.resume(result) + } + + override fun onInstallReferrerServiceDisconnected() { + it.resume(InstallReferrerResult.Unavailable) + } + } + runCatching { client.startConnection(listener) } + .onFailure { throwable -> + Timber.e(throwable, "Failed to start install referrer connection") + it.resume(InstallReferrerResult.Unavailable) + } + } + + private fun readReferrer( + client: InstallReferrerClient, + responseCode: Int, + ): InstallReferrerResult { + if (responseCode != InstallReferrerClient.InstallReferrerResponse.OK) { + Timber.w("Install referrer setup failed with response code: $responseCode") + return InstallReferrerResult.Unavailable + } + return runCatching { InstallReferrerResult.Success(client.installReferrer.installReferrer) } + .getOrElse { + Timber.e(it, "Failed to read install referrer") + InstallReferrerResult.Unavailable + } + } + + internal fun parseSurveyId(referrer: String): String? = + referrer.split('&').firstNotNullOfOrNull { part -> + val idx = part.indexOf('=') + if (idx > 0 && part.substring(0, idx) == DEFERRED_SURVEY_ID_KEY) { + part.substring(idx + 1).takeIf { it.isNotBlank() } + } else { + null + } + } + + private sealed interface InstallReferrerResult { + data class Success(val referrer: String) : InstallReferrerResult + + data object Unavailable : InstallReferrerResult + } + + companion object { + private const val DEFERRED_SURVEY_ID_KEY = "survey_id" + } +} 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 fe1dee2652..fafde570ca 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 @@ -29,6 +29,7 @@ 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.InstallReferrerManager import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.ui.common.SharedViewModel import org.groundplatform.android.usecases.session.ClearUserSessionUseCase @@ -53,6 +54,7 @@ constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val remoteConfig: FirebaseRemoteConfig, authenticationManager: AuthenticationManager, + private val installReferrerManager: InstallReferrerManager, ) : AbstractViewModel() { private val _navigationRequests: MutableSharedFlow = MutableSharedFlow() @@ -102,11 +104,13 @@ constructor( } else { MainUiState.NoActiveSurvey } - } else if (!reactivateLastSurvey()) { - MainUiState.NoActiveSurvey } else { - // Everything is fine, show the home screen - MainUiState.ShowHomeScreen + val deferredSurveyId = installReferrerManager.getDeferredSurveyId() + when { + deferredSurveyId != null -> MainUiState.ActiveSurveyById(deferredSurveyId) + reactivateLastSurvey() -> MainUiState.ShowHomeScreen + else -> MainUiState.NoActiveSurvey + } } } catch (e: Throwable) { Timber.e(e) diff --git a/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceFragment.kt b/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceFragment.kt index 2f70ca7b4f..1a7240a6de 100644 --- a/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceFragment.kt @@ -52,8 +52,9 @@ class TermsOfServiceFragment : AbstractFragment() { ): View = createComposeView { TermsOfServiceScreen( onNavigateUp = { findNavController().navigateUp() }, - onNavigateToSurveySelector = { - openSurveySelector(activity?.intent?.data?.let { surveyDeepLinkParser.parse(it) }) + onNavigateToSurveySelector = { deferredSurveyId -> + val explicitSurveyId = activity?.intent?.data?.let { surveyDeepLinkParser.parse(it) } + openSurveySelector(explicitSurveyId ?: deferredSurveyId) }, onLoadError = { popups.ErrorPopup().show(R.string.load_tos_failed) }, termsContent = { html -> TermsTextView(fromHtml(html, 0)) }, diff --git a/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceScreen.kt b/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceScreen.kt index c9176bf8de..95007609d2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceScreen.kt @@ -54,7 +54,7 @@ import org.jetbrains.compose.resources.stringResource @Composable fun TermsOfServiceScreen( onNavigateUp: () -> Unit, - onNavigateToSurveySelector: () -> Unit, + onNavigateToSurveySelector: (String?) -> Unit, onLoadError: () -> Unit, termsContent: @Composable (String) -> Unit, viewModel: TermsOfServiceViewModel = hiltViewModel(), @@ -64,7 +64,7 @@ fun TermsOfServiceScreen( LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { - is TosEvent.NavigateToSurveySelector -> onNavigateToSurveySelector() + is TosEvent.NavigateToSurveySelector -> onNavigateToSurveySelector(event.deferredSurveyId) is TosEvent.LoadError -> onLoadError() } } diff --git a/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModel.kt index cba7b00096..651bbf7fd1 100644 --- a/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.groundplatform.android.system.auth.AuthenticationManager +import org.groundplatform.android.system.deeplink.InstallReferrerManager import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.util.isPermissionDeniedException import org.groundplatform.domain.repository.TermsOfServiceRepositoryInterface @@ -51,7 +52,7 @@ sealed interface TosUiState { } sealed interface TosEvent { - object NavigateToSurveySelector : TosEvent + data class NavigateToSurveySelector(val deferredSurveyId: String?) : TosEvent object LoadError : TosEvent } @@ -62,6 +63,7 @@ class TermsOfServiceViewModel constructor( private val authManager: AuthenticationManager, private val termsOfServiceRepository: TermsOfServiceRepositoryInterface, + private val installReferrerManager: InstallReferrerManager, savedStateHandle: SavedStateHandle, ) : AbstractViewModel() { @@ -90,7 +92,8 @@ constructor( fun onAgreeButtonClicked() { viewModelScope.launch { termsOfServiceRepository.isTermsOfServiceAccepted = true - _events.send(TosEvent.NavigateToSurveySelector) + val deferredSurveyId = installReferrerManager.getDeferredSurveyId() + _events.send(TosEvent.NavigateToSurveySelector(deferredSurveyId)) } } diff --git a/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceScreenTest.kt index cc18c9f275..96256875e5 100644 --- a/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceScreenTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceScreenTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.test.runTest import org.groundplatform.android.R import org.groundplatform.android.getString import org.groundplatform.android.system.auth.AuthenticationManager +import org.groundplatform.android.system.deeplink.InstallReferrerManager import org.groundplatform.domain.model.TermsOfService import org.groundplatform.testing.FakeTermsOfServiceRepository import org.junit.Before @@ -50,6 +51,7 @@ class TermsOfServiceScreenTest { @get:Rule val composeTestRule = createComposeRule() @Mock lateinit var authManager: AuthenticationManager + @Mock lateinit var installReferrerManager: InstallReferrerManager private lateinit var fakeRepository: FakeTermsOfServiceRepository private lateinit var viewModel: TermsOfServiceViewModel @@ -66,12 +68,13 @@ class TermsOfServiceScreenTest { ) { fakeRepository.termsOfService = result val savedStateHandle = SavedStateHandle(mapOf("isViewOnly" to isViewOnly)) - viewModel = TermsOfServiceViewModel(authManager, fakeRepository, savedStateHandle) + viewModel = + TermsOfServiceViewModel(authManager, fakeRepository, installReferrerManager, savedStateHandle) } private fun setScreenContent( onNavigateUp: () -> Unit = {}, - onNavigateToSurveySelector: () -> Unit = {}, + onNavigateToSurveySelector: (String?) -> Unit = {}, onError: () -> Unit = {}, ) { composeTestRule.setContent { diff --git a/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModelTest.kt index f2a18b09c2..311aaacd13 100644 --- a/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModelTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/tos/TermsOfServiceViewModelTest.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.groundplatform.android.system.auth.AuthenticationManager +import org.groundplatform.android.system.deeplink.InstallReferrerManager import org.groundplatform.domain.model.TermsOfService import org.groundplatform.testing.FakeTermsOfServiceRepository import org.junit.After @@ -35,11 +36,13 @@ import org.mockito.Mock import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever @OptIn(ExperimentalCoroutinesApi::class) class TermsOfServiceViewModelTest { @Mock lateinit var authManager: AuthenticationManager + @Mock lateinit var installReferrerManager: InstallReferrerManager private lateinit var fakeRepository: FakeTermsOfServiceRepository private lateinit var viewModel: TermsOfServiceViewModel @@ -60,7 +63,8 @@ class TermsOfServiceViewModelTest { private fun setupViewModel(isViewOnly: Boolean = false) = runTest { val savedStateHandle = SavedStateHandle(mapOf("isViewOnly" to isViewOnly)) - viewModel = TermsOfServiceViewModel(authManager, fakeRepository, savedStateHandle) + viewModel = + TermsOfServiceViewModel(authManager, fakeRepository, installReferrerManager, savedStateHandle) advanceUntilIdle() } @@ -102,6 +106,19 @@ class TermsOfServiceViewModelTest { } } + @Test + fun `onAgreeButtonClicked forwards deferred survey id`() = runTest { + whenever(installReferrerManager.getDeferredSurveyId()).thenReturn(DEFERRED_SURVEY_ID) + setupViewModel() + + viewModel.events.test { + viewModel.onAgreeButtonClicked() + val event = awaitItem() + assertThat((event as TosEvent.NavigateToSurveySelector).deferredSurveyId) + .isEqualTo(DEFERRED_SURVEY_ID) + } + } + @Test fun `Load failure signs out when not view-only`() = runTest { fakeRepository.termsOfService = Result.failure(Exception("Failed to load")) @@ -141,6 +158,7 @@ class TermsOfServiceViewModelTest { companion object { private const val TERMS_TEXT = "Terms content" + private const val DEFERRED_SURVEY_ID = "survey_123" private val TEST_TOS = TermsOfService("1", TERMS_TEXT) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44e1ad2209..9a7c2024bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ [versions] androidCompileSdk = "37" androidCompileSdkMinor = "0" +androidInstallReferrer = "2.2" androidMinSdk = "24" androidTargetSdk = "36" androidMapsUtilsVersion = "4.5.1" @@ -83,6 +84,7 @@ uiautomatorVersion = "2.4.0" workVersion = "2.11.2" [libraries] +android-install-referrer = { module = "com.android.installreferrer:installreferrer", version.ref = "androidInstallReferrer" } android-maps-utils = { module = "com.google.maps.android:android-maps-utils", version.ref = "androidMapsUtilsVersion" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompatVersion" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayoutVersion" }