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" }