diff --git a/app/build.gradle b/app/build.gradle
index e16a436c41..97bbdb7479 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -220,6 +220,7 @@ dependencies {
// Google Play Services
implementation libs.play.services.auth
+ implementation libs.play.services.code.scanner
implementation libs.play.services.location
implementation libs.play.services.maps
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index da7a80e4c8..1227e4b485 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -63,6 +63,11 @@
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
+
+
+
+ android:host="@string/deeplink_host"
+ android:pathPrefix="@string/survey_deeplink_path"
+ android:scheme="https" />
+ val options =
+ GmsBarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
+ GmsBarcodeScanning.getClient(context, options)
+ .startScan()
+ .addOnSuccessListener { barcode ->
+ Timber.d("Scanned QR code with raw value: ${barcode.rawValue}")
+ coroutine.resume(barcode.rawValue?.let(Result::Success) ?: Result.Cancelled)
+ }
+ .addOnCanceledListener {
+ Timber.d("QR code scan cancelled by user")
+ coroutine.resume(Result.Cancelled)
+ }
+ .addOnFailureListener { e ->
+ Timber.e(e, "QR code scan failed with exception")
+ coroutine.resume(Result.Error(e))
+ }
+ }
+
+ sealed interface Result {
+ /** The scanner returned a decoded payload. */
+ data class Success(val text: String) : Result
+
+ /** The user dismissed the scanner without a successful scan. */
+ data object Cancelled : Result
+
+ /** The scan failed (camera unavailable, module install failure, etc.). */
+ data class Error(val cause: Throwable) : Result
+ }
+}
diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt
new file mode 100644
index 0000000000..2fae4225c2
--- /dev/null
+++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.groundplatform.android.ui.surveyselector
+
+sealed interface SurveySelectorEvent {
+ object NavigateToHome : SurveySelectorEvent
+
+ data class ShowError(val errorType: ErrorType) : SurveySelectorEvent
+
+ /** Errors surfaced from the Survey Selector screen for the host to display. */
+ sealed interface ErrorType {
+ /** Survey loading or activation timed out. */
+ data object Timeout : ErrorType
+
+ /** A generic error encountered while loading or activating a survey. */
+ data class Generic(val cause: Throwable) : ErrorType
+
+ /** The scanned QR code did not encode a valid survey link. */
+ data object InvalidQrCode : ErrorType
+ }
+}
diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt
index b3f4dee0c1..8e8401a5a8 100644
--- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorFragment.kt
@@ -24,7 +24,6 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
-import kotlinx.coroutines.TimeoutCancellationException
import org.groundplatform.android.R
import org.groundplatform.android.ui.common.AbstractFragment
import org.groundplatform.android.ui.common.BackPressListener
@@ -53,10 +52,13 @@ class SurveySelectorFragment : AbstractFragment(), BackPressListener {
findNavController().navigate(HomeScreenFragmentDirections.showHomeScreen())
},
onError = { error ->
- if (error is TimeoutCancellationException) {
- ephemeralPopups.ErrorPopup().show(R.string.survey_load_timeout_error)
- } else {
- ephemeralPopups.ErrorPopup().unknownError()
+ when (error) {
+ SurveySelectorEvent.ErrorType.Timeout ->
+ ephemeralPopups.ErrorPopup().show(R.string.survey_load_timeout_error)
+ is SurveySelectorEvent.ErrorType.Generic ->
+ ephemeralPopups.ErrorPopup().unknownError()
+ SurveySelectorEvent.ErrorType.InvalidQrCode ->
+ ephemeralPopups.ErrorPopup().show(R.string.invalid_survey_qr_code)
}
},
)
diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorScreen.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorScreen.kt
index 413d59e238..54c3ed2baa 100644
--- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorScreen.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorScreen.kt
@@ -19,18 +19,32 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.flowWithLifecycle
import org.groundplatform.android.R
+import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport
import org.groundplatform.android.ui.components.LoadingDialog
import org.groundplatform.android.ui.components.Toolbar
import org.groundplatform.android.ui.surveyselector.components.SurveyEmptyState
import org.groundplatform.android.ui.surveyselector.components.SurveySectionList
+import org.groundplatform.domain.model.Survey
+import org.groundplatform.domain.model.SurveyListItem
+import org.groundplatform.ui.theme.AppTheme
/**
* Stateful composable that handles ViewModel interactions and side effects for the Survey Selector
@@ -46,17 +60,17 @@ import org.groundplatform.android.ui.surveyselector.components.SurveySectionList
fun SurveySelectorScreen(
onBack: () -> Unit,
onNavigateToHomeScreen: () -> Unit,
- onError: (Throwable) -> Unit,
+ onError: (SurveySelectorEvent.ErrorType) -> Unit,
viewModel: SurveySelectorViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- // Collect and handle one-shot events in a LaunchedEffect
- LaunchedEffect(Unit) {
- viewModel.events.collect { event ->
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ LaunchedEffect(lifecycle) {
+ viewModel.events.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { event ->
when (event) {
is SurveySelectorEvent.NavigateToHome -> onNavigateToHomeScreen()
- is SurveySelectorEvent.ShowError -> onError(event.error)
+ is SurveySelectorEvent.ShowError -> onError(event.errorType)
}
}
}
@@ -67,6 +81,7 @@ fun SurveySelectorScreen(
onSignOut = viewModel::signOut,
onConfirmDelete = viewModel::confirmDelete,
onCardClick = viewModel::activateSurvey,
+ onScanQrCode = viewModel::scanQrCodeAndActivateSurvey,
)
}
@@ -78,6 +93,7 @@ fun SurveySelectorScreen(
* @param onSignOut Callback when the user attempts to sign out from the empty state.
* @param onConfirmDelete Callback when a local survey deletion is confirmed.
* @param onCardClick Callback when a survey card is clicked to activate it.
+ * @param onScanQrCode Callback when the user taps the scan-QR action.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -87,11 +103,27 @@ private fun SurveySelectorScreen(
onSignOut: () -> Unit,
onConfirmDelete: (String) -> Unit,
onCardClick: (String) -> Unit,
+ onScanQrCode: () -> Unit,
) {
Scaffold(
topBar = {
Toolbar(stringRes = R.string.surveys, showNavigationIcon = true, iconClick = onBack)
- }
+ },
+ floatingActionButton = {
+ if (!uiState.isLoading) {
+ ExtendedFloatingActionButton(
+ onClick = onScanQrCode,
+ icon = {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_qr_code_scanner),
+ contentDescription = null,
+ tint = Color.Black,
+ )
+ },
+ text = { Text(stringResource(R.string.join_survey)) },
+ )
+ }
+ },
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
when {
@@ -113,3 +145,70 @@ private fun SurveySelectorScreen(
}
}
}
+
+@Composable
+@Preview(showBackground = true, showSystemUi = true)
+@ExcludeFromJacocoGeneratedReport
+private fun PreviewSurveySelectorScreenEmpty() {
+ AppTheme {
+ SurveySelectorScreen(
+ uiState = SurveySelectorUiState(),
+ onBack = {},
+ onSignOut = {},
+ onConfirmDelete = {},
+ onCardClick = {},
+ onScanQrCode = {},
+ )
+ }
+}
+
+@Composable
+@Preview(showBackground = true, showSystemUi = true)
+@ExcludeFromJacocoGeneratedReport
+private fun PreviewSurveySelectorScreenWithSurveys() {
+ val dummySurveys =
+ listOf(
+ SurveyListItem("1", "Tree Survey", "Track tree growth", true, Survey.GeneralAccess.PUBLIC),
+ SurveyListItem(
+ "2",
+ "Water Survey",
+ "Check water quality",
+ false,
+ Survey.GeneralAccess.RESTRICTED,
+ ),
+ )
+
+ val uiState =
+ SurveySelectorUiState(
+ onDeviceSurveys = dummySurveys,
+ sharedSurveys = emptyList(),
+ publicSurveys = dummySurveys,
+ )
+
+ AppTheme {
+ SurveySelectorScreen(
+ uiState = uiState,
+ onBack = {},
+ onSignOut = {},
+ onConfirmDelete = {},
+ onCardClick = {},
+ onScanQrCode = {},
+ )
+ }
+}
+
+@Composable
+@Preview(showBackground = true, showSystemUi = true)
+@ExcludeFromJacocoGeneratedReport
+private fun PreviewSurveySelectorScreenLoading() {
+ AppTheme {
+ SurveySelectorScreen(
+ uiState = SurveySelectorUiState(isLoading = true),
+ onBack = {},
+ onSignOut = {},
+ onConfirmDelete = {},
+ onCardClick = {},
+ onScanQrCode = {},
+ )
+ }
+}
diff --git a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt
index f2b76c59e1..76612070fc 100644
--- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt
+++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModel.kt
@@ -21,6 +21,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -34,12 +35,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.groundplatform.android.di.coroutines.ApplicationScope
import org.groundplatform.android.di.coroutines.IoDispatcher
+import org.groundplatform.android.system.GmsQrCodeScanner
import org.groundplatform.android.ui.common.AbstractViewModel
import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase
import org.groundplatform.android.usecases.survey.ListAvailableSurveysUseCase
import org.groundplatform.android.usecases.survey.RemoveOfflineSurveyUseCase
import org.groundplatform.domain.model.SurveyListItem
import org.groundplatform.domain.repository.UserRepositoryInterface
+import org.groundplatform.domain.util.SurveyQrCodeParser
import timber.log.Timber
/** Represents view state and behaviors of the survey selector dialog. */
@@ -51,6 +54,8 @@ internal constructor(
@ApplicationScope private val externalScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
listAvailableSurveysUseCase: ListAvailableSurveysUseCase,
+ private val gmsQrCodeScanner: GmsQrCodeScanner,
+ private val surveyQrCodeParser: SurveyQrCodeParser,
private val removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase,
private val userRepository: UserRepositoryInterface,
savedStateHandle: SavedStateHandle,
@@ -58,7 +63,7 @@ internal constructor(
private val surveyIdToActivate: String? = savedStateHandle["surveyId"]
- private val _events = Channel()
+ private val _events = Channel(Channel.BUFFERED)
val events = _events.receiveAsFlow()
private val _isActivating = MutableStateFlow(false)
@@ -68,7 +73,7 @@ internal constructor(
.map { surveys -> surveys.sortedWith(compareBy({ !it.availableOffline }, { it.title })) }
.catch { error ->
Timber.e(error, "Failed to load available surveys")
- _events.send(SurveySelectorEvent.ShowError(error))
+ _events.send(SurveySelectorEvent.ShowError(error.toSurveySelectorError()))
emit(emptyList())
}
@@ -106,18 +111,44 @@ internal constructor(
if (result) {
_events.send(SurveySelectorEvent.NavigateToHome)
} else {
- _events.send(SurveySelectorEvent.ShowError(Exception("Survey activation failed")))
+ _events.send(
+ SurveySelectorEvent.ShowError(
+ Exception("Survey activation failed").toSurveySelectorError()
+ )
+ )
}
},
onFailure = {
Timber.e(it, "Failed to activate survey")
_isActivating.value = false
- _events.send(SurveySelectorEvent.ShowError(it))
+ _events.send(SurveySelectorEvent.ShowError(it.toSurveySelectorError()))
},
)
}
}
+ /** Launches the QR scanner and, on success, activates the encoded survey. */
+ fun scanQrCodeAndActivateSurvey() {
+ viewModelScope.launch {
+ when (val result = gmsQrCodeScanner.scan()) {
+ is GmsQrCodeScanner.Result.Success -> {
+ val surveyId = surveyQrCodeParser(result.text)
+ if (surveyId == null) {
+ _events.send(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.InvalidQrCode))
+ } else {
+ activateSurvey(surveyId)
+ }
+ }
+ is GmsQrCodeScanner.Result.Cancelled -> {
+ /* Nothing to do */
+ }
+ is GmsQrCodeScanner.Result.Error -> {
+ _events.send(SurveySelectorEvent.ShowError(result.cause.toSurveySelectorError()))
+ }
+ }
+ }
+ }
+
/** Signs out the current user. */
fun signOut() {
userRepository.signOut()
@@ -131,10 +162,8 @@ internal constructor(
fun confirmDelete(surveyId: String) {
externalScope.launch(ioDispatcher) { removeOfflineSurveyUseCase(surveyId) }
}
-}
-
-sealed interface SurveySelectorEvent {
- object NavigateToHome : SurveySelectorEvent
- data class ShowError(val error: Throwable) : SurveySelectorEvent
+ private fun Throwable.toSurveySelectorError(): SurveySelectorEvent.ErrorType =
+ if (this is TimeoutCancellationException) SurveySelectorEvent.ErrorType.Timeout
+ else SurveySelectorEvent.ErrorType.Generic(this)
}
diff --git a/app/src/main/res/drawable/ic_qr_code_scanner.xml b/app/src/main/res/drawable/ic_qr_code_scanner.xml
new file mode 100644
index 0000000000..ab81438209
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qr_code_scanner.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index d019c28b34..eb9a594751 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -246,4 +246,7 @@
Compartir ubicación
Escanea este código QR para ver el GeoJSON
Compartir
+
+ Unirse a una encuesta
+ Código QR de encuesta no reconocido
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index a6113662fe..e4f2a7dc56 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -225,4 +225,7 @@
Partager l’emplacement
Scannez ce code QR pour afficher le GeoJson
Partager
+
+ Rejoindre une enquête
+ Code QR d’enquête non reconnu
diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml
index 3f6a2bee0f..f162d84c3c 100644
--- a/app/src/main/res/values-lo/strings.xml
+++ b/app/src/main/res/values-lo/strings.xml
@@ -215,4 +215,7 @@
ແບ່ງປັນຕຳແໜ່ງ
ສະແກນ QR ນີ້ເພື່ອເບິ່ງ GeoJSON
ແບ່ງປັນ
+
+ ເຂົ້າຮ່ວມແບບສຳຫຼວດ
+ ບໍ່ຮັບຮູ້ລະຫັດ QR ແບບສຳຫຼວດນີ້
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 8731036478..ca8ca4e5e7 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -248,4 +248,7 @@
Partilhar localização
Leia este código QR para visualizar o GeoJson
Partilhar
+
+ Aderir ao inquérito
+ Código QR de inquérito não reconhecido
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 26bb96444c..7c564e9e2e 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -217,4 +217,7 @@
แชร์ตำแหน่ง
สแกนคิวอาร์โค้ดนี้เพื่อดู GeoJSON
แชร์
+
+ เข้าร่วมแบบสำรวจ
+ ไม่รู้จักรหัสคิวอาร์ของแบบสำรวจนี้
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 215f33b3f3..0c57714a95 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -219,4 +219,7 @@
Chia sẻ vị trí
Quét mã QR này để xem GeoJSON
Chia sẻ
+
+ Tham gia khảo sát
+ Không nhận diện được mã QR khảo sát
diff --git a/app/src/main/res/values/strings-untranslated.xml b/app/src/main/res/values/strings-untranslated.xml
index d1b4a80690..1ad4ead270 100644
--- a/app/src/main/res/values/strings-untranslated.xml
+++ b/app/src/main/res/values/strings-untranslated.xml
@@ -20,5 +20,6 @@
Ground
https://groundplatform.org/
-
+ ground-dev-sig.web.app
+ /android/survey/
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 86591c4765..0009fdbf29 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -244,4 +244,7 @@
Share location
Scan this QR code to view the GeoJson
Share
+
+ Join survey
+ Unrecognized survey QR code
diff --git a/app/src/test/java/org/groundplatform/android/system/GmsQrCodeScannerTest.kt b/app/src/test/java/org/groundplatform/android/system/GmsQrCodeScannerTest.kt
new file mode 100644
index 0000000000..7dc8318be1
--- /dev/null
+++ b/app/src/test/java/org/groundplatform/android/system/GmsQrCodeScannerTest.kt
@@ -0,0 +1,125 @@
+/*
+ * 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
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.gms.tasks.OnCanceledListener
+import com.google.android.gms.tasks.OnFailureListener
+import com.google.android.gms.tasks.OnSuccessListener
+import com.google.android.gms.tasks.Task
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
+import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.MockedStatic
+import org.mockito.Mockito.mockStatic
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class GmsQrCodeScannerTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val client: GmsBarcodeScanner = mock()
+ private val task: Task = mock>()
+ private lateinit var staticScanning: MockedStatic
+ private lateinit var scanner: GmsQrCodeScanner
+
+ @Before
+ fun setUp() {
+ staticScanning = mockStatic(GmsBarcodeScanning::class.java)
+ staticScanning
+ .`when` { GmsBarcodeScanning.getClient(any(), any()) }
+ .thenReturn(client)
+ whenever(client.startScan()).thenReturn(task)
+ whenever(task.addOnSuccessListener(any())).thenReturn(task)
+ whenever(task.addOnCanceledListener(any())).thenReturn(task)
+ whenever(task.addOnFailureListener(any())).thenReturn(task)
+ scanner = GmsQrCodeScanner(context)
+ }
+
+ @After
+ fun tearDown() {
+ staticScanning.close()
+ }
+
+ @Test
+ fun `scan returns Success when barcode has raw value`() = runTest {
+ val barcode = mock { whenever(it.rawValue).thenReturn(PAYLOAD) }
+ onSuccess(barcode)
+
+ assertEquals(GmsQrCodeScanner.Result.Success(PAYLOAD), scanner.scan())
+ }
+
+ @Test
+ fun `scan returns Cancelled when barcode raw value is null`() = runTest {
+ val barcode = mock { whenever(it.rawValue).thenReturn(null) }
+ onSuccess(barcode)
+
+ assertEquals(GmsQrCodeScanner.Result.Cancelled, scanner.scan())
+ }
+
+ @Test
+ fun `scan returns Cancelled when scanner is closed`() = runTest {
+ doAnswer { invocation ->
+ (invocation.arguments[0] as OnCanceledListener).onCanceled()
+ task
+ }
+ .whenever(task)
+ .addOnCanceledListener(any())
+
+ assertEquals(GmsQrCodeScanner.Result.Cancelled, scanner.scan())
+ }
+
+ @Test
+ fun `scan returns Error when scanner fails`() = runTest {
+ val cause = RuntimeException()
+ doAnswer { invocation ->
+ (invocation.arguments[0] as OnFailureListener).onFailure(cause)
+ task
+ }
+ .whenever(task)
+ .addOnFailureListener(any())
+
+ val result = scanner.scan()
+ assertTrue(result is GmsQrCodeScanner.Result.Error)
+ assertEquals(cause, (result as GmsQrCodeScanner.Result.Error).cause)
+ }
+
+ private fun onSuccess(barcode: Barcode) {
+ doAnswer { invocation ->
+ @Suppress("UNCHECKED_CAST")
+ (invocation.arguments[0] as OnSuccessListener).onSuccess(barcode)
+ task
+ }
+ .whenever(task)
+ .addOnSuccessListener(any())
+ }
+
+ companion object {
+ private const val PAYLOAD = "https://ground.example/survey/123"
+ }
+}
diff --git a/app/src/test/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModelTest.kt
index a3522b1be2..92c5efde7f 100644
--- a/app/src/test/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModelTest.kt
+++ b/app/src/test/java/org/groundplatform/android/ui/surveyselector/SurveySelectorViewModelTest.kt
@@ -19,18 +19,25 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest
+import kotlin.test.assertFailsWith
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.withTimeout
import org.groundplatform.android.BaseHiltTest
+import org.groundplatform.android.system.GmsQrCodeScanner
import org.groundplatform.android.usecases.survey.ActivateSurveyUseCase
import org.groundplatform.android.usecases.survey.ListAvailableSurveysUseCase
import org.groundplatform.android.usecases.survey.RemoveOfflineSurveyUseCase
import org.groundplatform.domain.model.Survey
import org.groundplatform.domain.model.SurveyListItem
import org.groundplatform.domain.repository.UserRepositoryInterface
+import org.groundplatform.domain.util.SurveyQrCodeParser
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -45,6 +52,8 @@ class SurveySelectorViewModelTest : BaseHiltTest() {
@Mock lateinit var activateSurveyUseCase: ActivateSurveyUseCase
@Mock lateinit var listAvailableSurveysUseCase: ListAvailableSurveysUseCase
+ @Mock lateinit var parseSurveyQrCodeUseCase: SurveyQrCodeParser
+ @Mock lateinit var qrCodeScanner: GmsQrCodeScanner
@Mock lateinit var removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase
@Mock lateinit var userRepository: UserRepositoryInterface
@@ -68,6 +77,8 @@ class SurveySelectorViewModelTest : BaseHiltTest() {
externalScope,
ioDispatcher,
listAvailableSurveysUseCase,
+ qrCodeScanner,
+ parseSurveyQrCodeUseCase,
removeOfflineSurveyUseCase,
userRepository,
savedStateHandle,
@@ -104,10 +115,40 @@ class SurveySelectorViewModelTest : BaseHiltTest() {
viewModel.events.test {
viewModel.activateSurvey("1")
- assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.ShowError(error))
+ assertThat(awaitItem())
+ .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error)))
}
}
+ @Test
+ fun `activateSurvey emits Generic error when use case returns false`() = runWithTestDispatcher {
+ createViewModel()
+ whenever(activateSurveyUseCase("1")).thenReturn(false)
+
+ viewModel.events.test {
+ viewModel.activateSurvey("1")
+ val event = awaitItem()
+ assertThat(event).isInstanceOf(SurveySelectorEvent.ShowError::class.java)
+ val errorType = (event as SurveySelectorEvent.ShowError).errorType
+ assertThat(errorType).isInstanceOf(SurveySelectorEvent.ErrorType.Generic::class.java)
+ assertThat((errorType as SurveySelectorEvent.ErrorType.Generic).cause)
+ }
+ }
+
+ @Test
+ fun `activateSurvey emits Timeout when use case throws TimeoutCancellationException`() =
+ runWithTestDispatcher {
+ createViewModel()
+ val timeout = assertFailsWith { withTimeout(1) { delay(2) } }
+ whenever(activateSurveyUseCase("1")).thenThrow(timeout)
+
+ viewModel.events.test {
+ viewModel.activateSurvey("1")
+ assertThat(awaitItem())
+ .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Timeout))
+ }
+ }
+
@Test
fun `activateSurvey from deeplink works correctly`() = runWithTestDispatcher {
val savedState = SavedStateHandle(mapOf("surveyId" to "deeplink-id"))
@@ -125,7 +166,76 @@ class SurveySelectorViewModelTest : BaseHiltTest() {
createViewModel(savedStateHandle = savedState)
viewModel.events.test {
- assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.ShowError(error))
+ assertThat(awaitItem())
+ .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error)))
+ }
+ }
+
+ @Test
+ fun `scanQrCodeAndActivateSurvey activates parsed survey`() = runWithTestDispatcher {
+ createViewModel()
+ val payload = "https://groundplatform.org/android/survey/xyz"
+ whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Success(payload))
+ whenever(parseSurveyQrCodeUseCase(payload)).thenReturn("xyz")
+ whenever(activateSurveyUseCase("xyz")).thenReturn(true)
+
+ viewModel.events.test {
+ viewModel.scanQrCodeAndActivateSurvey()
+ assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.NavigateToHome)
+ }
+ }
+
+ @Test
+ fun `scanQrCodeAndActivateSurvey emits invalid event for bad payload`() = runWithTestDispatcher {
+ createViewModel()
+ whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Success("not a url"))
+ whenever(parseSurveyQrCodeUseCase("not a url")).thenReturn(null)
+
+ viewModel.events.test {
+ viewModel.scanQrCodeAndActivateSurvey()
+ assertThat(awaitItem())
+ .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.InvalidQrCode))
+ }
+ }
+
+ @Test
+ fun `scanQrCodeAndActivateSurvey is silent on cancellation`() = runWithTestDispatcher {
+ createViewModel()
+ whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Cancelled)
+
+ viewModel.events.test {
+ viewModel.scanQrCodeAndActivateSurvey()
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `scanQrCodeAndActivateSurvey emits generic error when there's a problem scanning`() =
+ runWithTestDispatcher {
+ createViewModel()
+ val error = RuntimeException("camera unavailable")
+ whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Error(error))
+
+ viewModel.events.test {
+ viewModel.scanQrCodeAndActivateSurvey()
+ assertThat(awaitItem())
+ .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error)))
+ }
+ }
+
+ @Test
+ fun `surveyList failure emits Generic error event`() = runWithTestDispatcher {
+ val error = RuntimeException()
+ whenever(listAvailableSurveysUseCase()).thenReturn(flow { throw error })
+ createViewModel()
+
+ viewModel.events.test {
+ viewModel.uiState.test {
+ awaitItem()
+ cancelAndIgnoreRemainingEvents()
+ }
+ assertThat(awaitItem())
+ .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error)))
}
}
diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/SurveyQrCodeParser.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/SurveyQrCodeParser.kt
new file mode 100644
index 0000000000..7ba08e865c
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/SurveyQrCodeParser.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.domain.util
+
+/**
+ * Parses a scanned QR code and returns the encoded survey ID, or `null` if there isn't a valid
+ * survey deep link.
+ *
+ * @param deepLinkHost The host of the deep link URL (e.g. "groundplatform.org")
+ * @param deepLinkPath The path of the deep link URL (e.g. "/android/survey/")
+ */
+class SurveyQrCodeParser(private val deepLinkHost: String, private val deepLinkPath: String) {
+ operator fun invoke(payload: String): String? {
+ val regex =
+ Regex(
+ """^https?://${Regex.escape(deepLinkHost)}${Regex.escape(deepLinkPath)}([A-Za-z0-9_-]+)/?$"""
+ )
+ return regex.matchEntire(payload.trim())?.groupValues?.getOrNull(1)
+ }
+}
diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt
new file mode 100644
index 0000000000..9e6df63237
--- /dev/null
+++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class SurveyQrCodeParserTest {
+
+ private val parser = SurveyQrCodeParser("groundplatform.org", "/android/survey/")
+
+ @Test
+ fun `extracts survey id from canonical https url`() {
+ assertEquals("testId", parser("https://groundplatform.org/android/survey/testId"))
+ }
+
+ @Test
+ fun `accepts http scheme`() {
+ assertEquals("testId", parser("http://groundplatform.org/android/survey/testId"))
+ }
+
+ @Test
+ fun `tolerates surrounding whitespace`() {
+ assertEquals("testId", parser(" https://groundplatform.org/android/survey/testId "))
+ }
+
+ @Test
+ fun `tolerates trailing slash`() {
+ assertEquals("testId", parser("https://groundplatform.org/android/survey/testId/"))
+ }
+
+ @Test
+ fun `rejects wrong host`() {
+ assertNull(parser("https://example.com/android/survey/testId"))
+ }
+
+ @Test
+ fun `rejects wrong path`() {
+ assertNull(parser("https://groundplatform.org/web/survey/testId"))
+ }
+
+ @Test
+ fun `rejects empty id`() {
+ assertNull(parser("https://groundplatform.org/android/survey/"))
+ }
+
+ @Test
+ fun `rejects non-url payload`() {
+ assertNull(parser("just a plain string"))
+ }
+
+ @Test
+ fun `rejects id with disallowed characters`() {
+ assertNull(parser("https://groundplatform.org/android/survey/abc 123"))
+ }
+
+ @Test
+ fun `host is configurable`() {
+ val customParser = SurveyQrCodeParser("staging.example.org", "/android/survey/")
+ assertEquals("testId", customParser("https://staging.example.org/android/survey/testId"))
+ assertNull(customParser("https://groundplatform.org/android/survey/testId"))
+ }
+
+ @Test
+ fun `path is configurable`() {
+ val customParser = SurveyQrCodeParser("groundplatform.org", "/web/survey/")
+ assertEquals("testId", customParser("https://groundplatform.org/web/survey/testId"))
+ assertNull(customParser("https://groundplatform.org/android/survey/testId"))
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f7afa220f1..9fdc910a07 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -64,6 +64,7 @@ orchestratorVersion = "1.6.1"
ossLicensesPluginVersion = "0.11.0"
perfPluginVersion = "2.0.2"
playServicesAuthVersion = "21.3.0"
+playServicesCodeScannerVersion = "16.1.0"
playServicesMapsVersion = "20.0.0"
playServicesOssLicensesVersion = "17.5.1"
preferenceKtx = "1.2.1"
@@ -171,6 +172,7 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "
oss-licenses-plugin = { module = "com.google.android.gms:oss-licenses-plugin", version.ref = "ossLicensesPluginVersion" }
perf-plugin = { module = "com.google.firebase:perf-plugin", version.ref = "perfPluginVersion" }
play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "playServicesAuthVersion" }
+play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "playServicesCodeScannerVersion" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesAuthVersion" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMapsVersion" }
play-services-oss-licenses = { module = "com.google.android.gms:play-services-oss-licenses", version.ref = "playServicesOssLicensesVersion" }