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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }

Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MainUiState?> = MutableSharedFlow()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,7 +52,7 @@ sealed interface TosUiState {
}

sealed interface TosEvent {
object NavigateToSurveySelector : TosEvent
data class NavigateToSurveySelector(val deferredSurveyId: String?) : TosEvent

object LoadError : TosEvent
}
Expand All @@ -62,6 +63,7 @@ class TermsOfServiceViewModel
constructor(
private val authManager: AuthenticationManager,
private val termsOfServiceRepository: TermsOfServiceRepositoryInterface,
private val installReferrerManager: InstallReferrerManager,
savedStateHandle: SavedStateHandle,
) : AbstractViewModel() {

Expand Down Expand Up @@ -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))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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()
}

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
[versions]
androidCompileSdk = "37"
androidCompileSdkMinor = "0"
androidInstallReferrer = "2.2"
androidMinSdk = "24"
androidTargetSdk = "36"
androidMapsUtilsVersion = "4.5.1"
Expand Down Expand Up @@ -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" }
Expand Down
Loading