From 60ad9e050fa0449602fe3925e9c84cd0cbc9718c Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 29 Apr 2026 16:19:22 +0200 Subject: [PATCH 01/11] add use case to parse survey deeplink --- app/src/main/AndroidManifest.xml | 6 +- .../android/di/UseCaseModule.kt | 10 +++ .../main/res/values/strings-untranslated.xml | 3 +- .../survey/ParseSurveyQrCodeUseCase.kt | 34 +++++++ .../survey/ParseSurveyQrCodeUseCaseTest.kt | 90 +++++++++++++++++++ 5 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt create mode 100644 core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da7a80e4c8..ef9c1b3a20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -78,9 +78,9 @@ + android:host="@string/deeplink_host" + android:pathPrefix="@string/survey_deeplink_path" + android:scheme="https" /> Ground https://groundplatform.org/ - + groundplatform.org + /android/survey/ diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt new file mode 100644 index 0000000000..93c3d68877 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.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.domain.usecases.survey + +/** + * Parses a scanned QR code and returns the encoded survey ID, or `null` if there isn't a valid + * survey deep link. + */ +class ParseSurveyQrCodeUseCase( + 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/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt new file mode 100644 index 0000000000..b5eaa861d8 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt @@ -0,0 +1,90 @@ +/* + * 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.usecases.survey + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ParseSurveyQrCodeUseCaseTest { + + private val parseSurveyQrCode = ParseSurveyQrCodeUseCase("groundplatform.org", "/android/survey/") + + @Test + fun `extracts survey id from canonical https url`() { + assertEquals( + "testId", + parseSurveyQrCode("https://groundplatform.org/android/survey/testId"), + ) + } + + @Test + fun `accepts http scheme`() { + assertEquals("testId", parseSurveyQrCode("http://groundplatform.org/android/survey/testId")) + } + + @Test + fun `tolerates surrounding whitespace`() { + assertEquals( + "testId", + parseSurveyQrCode(" https://groundplatform.org/android/survey/testId "), + ) + } + + @Test + fun `tolerates trailing slash`() { + assertEquals("testId", parseSurveyQrCode("https://groundplatform.org/android/survey/testId/")) + } + + @Test + fun `rejects wrong host`() { + assertNull(parseSurveyQrCode("https://example.com/android/survey/testId")) + } + + @Test + fun `rejects wrong path`() { + assertNull(parseSurveyQrCode("https://groundplatform.org/web/survey/testId")) + } + + @Test + fun `rejects empty id`() { + assertNull(parseSurveyQrCode("https://groundplatform.org/android/survey/")) + } + + @Test + fun `rejects non-url payload`() { + assertNull(parseSurveyQrCode("just a plain string")) + } + + @Test + fun `rejects id with disallowed characters`() { + assertNull(parseSurveyQrCode("https://groundplatform.org/android/survey/abc 123")) + } + + @Test + fun `host is configurable`() { + val parser = ParseSurveyQrCodeUseCase("staging.example.org", "/android/survey/") + assertEquals("testId", parser("https://staging.example.org/android/survey/testId")) + assertNull(parser("https://groundplatform.org/android/survey/testId")) + } + + @Test + fun `path is configurable`() { + val parser = ParseSurveyQrCodeUseCase("groundplatform.org", "/web/survey/") + assertEquals("testId", parser("https://groundplatform.org/web/survey/testId")) + assertNull(parser("https://groundplatform.org/android/survey/testId")) + } +} From 710a2a39ce407125ea4b43b64cfb83c4e4a458f5 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 29 Apr 2026 16:21:58 +0200 Subject: [PATCH 02/11] add dependencies for ML kit barcode scanner along with the implementation GmsQrCodeScanner.kt --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 5 ++ .../android/system/GmsQrCodeScanner.kt | 49 +++++++++++++++++++ .../domain/model/qrscanner/QrScanResult.kt | 26 ++++++++++ gradle/libs.versions.toml | 2 + 5 files changed, 83 insertions(+) create mode 100644 app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt create mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt 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 ef9c1b3a20..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}" /> + + + + barcode.rawValue?.let(QrScanResult::Success) ?: QrScanResult.Cancelled + }, + onFailure = { error -> + if (error is MlKitException && error.errorCode == MlKitException.CODE_SCANNER_CANCELLED) { + QrScanResult.Cancelled + } else { + QrScanResult.Error(error) + } + }, + ) + } +} diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt new file mode 100644 index 0000000000..1131dafab3 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt @@ -0,0 +1,26 @@ +/* + * 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.model.qrscanner +sealed interface QrScanResult { + /** The scanner returned a decoded payload. */ + data class Success(val text: String) : QrScanResult + + /** The user dismissed the scanner without a successful scan. */ + data object Cancelled : QrScanResult + + /** The scan failed (camera unavailable, module install failure, etc.). */ + data class Error(val cause: Throwable) : QrScanResult +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c340c5d7d3..3eb86fdaa7 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" } From 2343cf86c8df8a707f5fd1062cc0dc6e6c665e2a Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 29 Apr 2026 16:23:10 +0200 Subject: [PATCH 03/11] implement button to call scanner from SurveySelectorScreen.kt --- .../ui/surveyselector/SurveySelectorEvent.kt | 19 ++++ .../surveyselector/SurveySelectorFragment.kt | 12 +- .../ui/surveyselector/SurveySelectorScreen.kt | 104 +++++++++++++++++- .../surveyselector/SurveySelectorViewModel.kt | 47 ++++++-- .../main/res/drawable/ic_qr_code_scanner.xml | 24 ++++ app/src/main/res/values/strings.xml | 3 + .../SurveySelectorViewModelTest.kt | 64 ++++++++++- 7 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt create mode 100644 app/src/main/res/drawable/ic_qr_code_scanner.xml 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..1c849f25e4 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt @@ -0,0 +1,19 @@ +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 + } +} \ No newline at end of file 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..43f659fdc7 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,29 @@ 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.compose.collectAsStateWithLifecycle 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,7 +57,7 @@ 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() @@ -56,7 +67,7 @@ fun SurveySelectorScreen( viewModel.events.collect { event -> when (event) { is SurveySelectorEvent.NavigateToHome -> onNavigateToHomeScreen() - is SurveySelectorEvent.ShowError -> onError(event.error) + is SurveySelectorEvent.ShowError -> onError(event.errorType) } } } @@ -67,6 +78,7 @@ fun SurveySelectorScreen( onSignOut = viewModel::signOut, onConfirmDelete = viewModel::confirmDelete, onCardClick = viewModel::activateSurvey, + onScanQrCode = viewModel::scanQrCodeAndActivateSurvey, ) } @@ -78,6 +90,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 +100,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 +142,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..6c17b8c883 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,11 +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.usecases.survey.ParseSurveyQrCodeUseCase import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.qrscanner.QrScanResult import org.groundplatform.domain.repository.UserRepositoryInterface import timber.log.Timber @@ -51,6 +55,8 @@ internal constructor( @ApplicationScope private val externalScope: CoroutineScope, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, listAvailableSurveysUseCase: ListAvailableSurveysUseCase, + private val gmsQrCodeScanner: GmsQrCodeScanner, + private val parseSurveyQrCodeUseCase: ParseSurveyQrCodeUseCase, private val removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase, private val userRepository: UserRepositoryInterface, savedStateHandle: SavedStateHandle, @@ -68,7 +74,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 +112,45 @@ 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 QrScanResult.Success -> { + val surveyId = parseSurveyQrCodeUseCase(result.text) + if (surveyId == null) { + _events.send(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.InvalidQrCode)) + } else { + activateSurvey(surveyId) + } + } + is QrScanResult.Cancelled -> { + /* Nothing to do */ + } + is QrScanResult.Error -> { + Timber.e(result.cause, "QR scan failed") + _events.send(SurveySelectorEvent.ShowError(result.cause.toSurveySelectorError())) + } + } + } + } + /** Signs out the current user. */ fun signOut() { userRepository.signOut() @@ -131,10 +164,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..5719c71a68 --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code_scanner.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86591c4765..b01bb0d58f 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 + Invalid survey QR code 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..b6730be995 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 @@ -25,11 +25,14 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope 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.usecases.survey.ParseSurveyQrCodeUseCase import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem +import org.groundplatform.domain.model.qrscanner.QrScanResult import org.groundplatform.domain.repository.UserRepositoryInterface import org.junit.Before import org.junit.Test @@ -45,6 +48,8 @@ class SurveySelectorViewModelTest : BaseHiltTest() { @Mock lateinit var activateSurveyUseCase: ActivateSurveyUseCase @Mock lateinit var listAvailableSurveysUseCase: ListAvailableSurveysUseCase + @Mock lateinit var parseSurveyQrCodeUseCase: ParseSurveyQrCodeUseCase + @Mock lateinit var qrCodeScanner: GmsQrCodeScanner @Mock lateinit var removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase @Mock lateinit var userRepository: UserRepositoryInterface @@ -68,6 +73,8 @@ class SurveySelectorViewModelTest : BaseHiltTest() { externalScope, ioDispatcher, listAvailableSurveysUseCase, + qrCodeScanner, + parseSurveyQrCodeUseCase, removeOfflineSurveyUseCase, userRepository, savedStateHandle, @@ -104,7 +111,8 @@ class SurveySelectorViewModelTest : BaseHiltTest() { viewModel.events.test { viewModel.activateSurvey("1") - assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.ShowError(error)) + assertThat(awaitItem()) + .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error))) } } @@ -117,6 +125,57 @@ class SurveySelectorViewModelTest : BaseHiltTest() { viewModel.events.test { assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.NavigateToHome) } } + @Test + fun `scanQrCodeAndActivateSurvey activates parsed survey`() = runWithTestDispatcher { + createViewModel() + val payload = "https://groundplatform.org/android/survey/xyz" + whenever(qrCodeScanner.scan()).thenReturn(QrScanResult.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(QrScanResult.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(QrScanResult.Cancelled) + + viewModel.events.test { + viewModel.scanQrCodeAndActivateSurvey() + expectNoEvents() + } + } + + @Test + fun `scanQrCodeAndActivateSurvey surfaces scanner error`() = runWithTestDispatcher { + createViewModel() + val error = RuntimeException("camera unavailable") + whenever(qrCodeScanner.scan()).thenReturn(QrScanResult.Error(error)) + + viewModel.events.test { + viewModel.scanQrCodeAndActivateSurvey() + assertThat(awaitItem()) + .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error))) + } + } + @Test fun `activateSurvey from deeplink shows error on failure`() = runWithTestDispatcher { val savedState = SavedStateHandle(mapOf("surveyId" to "bad-id")) @@ -125,7 +184,8 @@ class SurveySelectorViewModelTest : BaseHiltTest() { createViewModel(savedStateHandle = savedState) viewModel.events.test { - assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.ShowError(error)) + assertThat(awaitItem()) + .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error))) } } From c04bf4362020c485512b0654b10941c1661e9ed7 Mon Sep 17 00:00:00 2001 From: andreia Date: Wed, 29 Apr 2026 17:04:12 +0200 Subject: [PATCH 04/11] fix code formatting --- .../ui/surveyselector/SurveySelectorEvent.kt | 17 ++++++++++++++++- .../main/res/drawable/ic_qr_code_scanner.xml | 18 ++++++++++-------- .../main/res/values/strings-untranslated.xml | 2 +- .../survey/ParseSurveyQrCodeUseCase.kt | 6 +----- .../survey/ParseSurveyQrCodeUseCaseTest.kt | 5 +---- 5 files changed, 29 insertions(+), 19 deletions(-) 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 index 1c849f25e4..2fae4225c2 100644 --- a/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt +++ b/app/src/main/java/org/groundplatform/android/ui/surveyselector/SurveySelectorEvent.kt @@ -1,3 +1,18 @@ +/* + * 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 { @@ -16,4 +31,4 @@ sealed interface SurveySelectorEvent { /** The scanned QR code did not encode a valid survey link. */ data object InvalidQrCode : ErrorType } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/ic_qr_code_scanner.xml b/app/src/main/res/drawable/ic_qr_code_scanner.xml index 5719c71a68..ab81438209 100644 --- a/app/src/main/res/drawable/ic_qr_code_scanner.xml +++ b/app/src/main/res/drawable/ic_qr_code_scanner.xml @@ -1,11 +1,13 @@ + + + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + android:fillColor="#e3e3e3" + android:pathData="M80,280v-200h200v80L160,160v120L80,280ZM80,880v-200h80v120h120v80L80,880ZM680,880v-80h120v-120h80v200L680,880ZM800,280v-120L680,160v-80h200v200h-80ZM700,700h60v60h-60v-60ZM700,580h60v60h-60v-60ZM640,640h60v60h-60v-60ZM580,700h60v60h-60v-60ZM520,640h60v60h-60v-60ZM640,520h60v60h-60v-60ZM580,580h60v60h-60v-60ZM520,520h60v60h-60v-60ZM760,200v240L520,440v-240h240ZM440,520v240L200,760v-240h240ZM440,200v240L200,440v-240h240ZM380,700v-120L260,580v120h120ZM380,380v-120L260,260v120h120ZM700,380v-120L580,260v120h120Z" /> diff --git a/app/src/main/res/values/strings-untranslated.xml b/app/src/main/res/values/strings-untranslated.xml index 4c7a8c5902..1ad4ead270 100644 --- a/app/src/main/res/values/strings-untranslated.xml +++ b/app/src/main/res/values/strings-untranslated.xml @@ -20,6 +20,6 @@ Ground https://groundplatform.org/ - groundplatform.org + ground-dev-sig.web.app /android/survey/ diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt index 93c3d68877..74f4b05a3d 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt @@ -19,11 +19,7 @@ package org.groundplatform.domain.usecases.survey * Parses a scanned QR code and returns the encoded survey ID, or `null` if there isn't a valid * survey deep link. */ -class ParseSurveyQrCodeUseCase( - private val deepLinkHost: String, - private val deepLinkPath: String, -) { - +class ParseSurveyQrCodeUseCase(private val deepLinkHost: String, private val deepLinkPath: String) { operator fun invoke(payload: String): String? { val regex = Regex( diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt index b5eaa861d8..a1ea4d0959 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt @@ -25,10 +25,7 @@ class ParseSurveyQrCodeUseCaseTest { @Test fun `extracts survey id from canonical https url`() { - assertEquals( - "testId", - parseSurveyQrCode("https://groundplatform.org/android/survey/testId"), - ) + assertEquals("testId", parseSurveyQrCode("https://groundplatform.org/android/survey/testId")) } @Test From 4205d4ef3feb69ec1b6b53d2cb1288c3c23b8c4c Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 4 May 2026 14:08:19 +0200 Subject: [PATCH 05/11] update translations --- app/src/main/res/values-es/strings.xml | 3 +++ app/src/main/res/values-fr/strings.xml | 3 +++ app/src/main/res/values-lo/strings.xml | 3 +++ app/src/main/res/values-pt/strings.xml | 3 +++ app/src/main/res/values-th/strings.xml | 3 +++ app/src/main/res/values-vi/strings.xml | 3 +++ app/src/main/res/values/strings.xml | 2 +- 7 files changed, 19 insertions(+), 1 deletion(-) 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.xml b/app/src/main/res/values/strings.xml index b01bb0d58f..0009fdbf29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,5 +246,5 @@ Share Join survey - Invalid survey QR code + Unrecognized survey QR code From 7b9347fd4987452b7dd37b143eac1a7c169e423f Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 4 May 2026 15:28:35 +0200 Subject: [PATCH 06/11] rename use case to parser --- .../android/di/UseCaseModule.kt | 6 +-- .../surveyselector/SurveySelectorViewModel.kt | 11 +++--- .../SurveySelectorViewModelTest.kt | 13 +++---- .../SurveyQrCodeParser.kt} | 7 +++- .../SurveyQrCodeParserTest.kt} | 37 ++++++++++--------- 5 files changed, 38 insertions(+), 36 deletions(-) rename core/domain/src/commonMain/kotlin/org/groundplatform/domain/{usecases/survey/ParseSurveyQrCodeUseCase.kt => util/SurveyQrCodeParser.kt} (76%) rename core/domain/src/commonTest/kotlin/org/groundplatform/domain/{usecases/survey/ParseSurveyQrCodeUseCaseTest.kt => util/SurveyQrCodeParserTest.kt} (53%) diff --git a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt index 89591fd0a9..9373438785 100644 --- a/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt +++ b/app/src/main/java/org/groundplatform/android/di/UseCaseModule.kt @@ -27,10 +27,10 @@ import org.groundplatform.domain.repository.SurveyRepositoryInterface import org.groundplatform.domain.repository.UserRepositoryInterface import org.groundplatform.domain.usecases.GetLoiReportUseCase import org.groundplatform.domain.usecases.submission.SubmitDataUseCase -import org.groundplatform.domain.usecases.survey.ParseSurveyQrCodeUseCase import org.groundplatform.domain.usecases.survey.SyncSurveyUseCase import org.groundplatform.domain.usecases.user.GetUserSettingsUseCase import org.groundplatform.domain.usecases.user.UpdateUserSettingsUseCase +import org.groundplatform.domain.util.SurveyQrCodeParser @InstallIn(SingletonComponent::class) @Module @@ -61,8 +61,8 @@ object UseCaseModule { ) = SubmitDataUseCase(loiRepository, submissionRepository) @Provides - fun providesParseSurveyQrCodeUseCase(resources: Resources) = - ParseSurveyQrCodeUseCase( + fun providesSurveyQrCodeParser(resources: Resources) = + SurveyQrCodeParser( deepLinkHost = resources.getString(R.string.deeplink_host), deepLinkPath = resources.getString(R.string.survey_deeplink_path), ) 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 6c17b8c883..894a914470 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 @@ -40,10 +40,9 @@ 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.usecases.survey.ParseSurveyQrCodeUseCase import org.groundplatform.domain.model.SurveyListItem -import org.groundplatform.domain.model.qrscanner.QrScanResult 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. */ @@ -56,7 +55,7 @@ internal constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, listAvailableSurveysUseCase: ListAvailableSurveysUseCase, private val gmsQrCodeScanner: GmsQrCodeScanner, - private val parseSurveyQrCodeUseCase: ParseSurveyQrCodeUseCase, + private val parseSurveyQrCodeUseCase: SurveyQrCodeParser, private val removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase, private val userRepository: UserRepositoryInterface, savedStateHandle: SavedStateHandle, @@ -132,7 +131,7 @@ internal constructor( fun scanQrCodeAndActivateSurvey() { viewModelScope.launch { when (val result = gmsQrCodeScanner.scan()) { - is QrScanResult.Success -> { + is GmsQrCodeScanner.Result.Success -> { val surveyId = parseSurveyQrCodeUseCase(result.text) if (surveyId == null) { _events.send(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.InvalidQrCode)) @@ -140,10 +139,10 @@ internal constructor( activateSurvey(surveyId) } } - is QrScanResult.Cancelled -> { + is GmsQrCodeScanner.Result.Cancelled -> { /* Nothing to do */ } - is QrScanResult.Error -> { + is GmsQrCodeScanner.Result.Error -> { Timber.e(result.cause, "QR scan failed") _events.send(SurveySelectorEvent.ShowError(result.cause.toSurveySelectorError())) } 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 b6730be995..d48799e69b 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 @@ -29,11 +29,10 @@ 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.usecases.survey.ParseSurveyQrCodeUseCase import org.groundplatform.domain.model.Survey import org.groundplatform.domain.model.SurveyListItem -import org.groundplatform.domain.model.qrscanner.QrScanResult 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 @@ -48,7 +47,7 @@ class SurveySelectorViewModelTest : BaseHiltTest() { @Mock lateinit var activateSurveyUseCase: ActivateSurveyUseCase @Mock lateinit var listAvailableSurveysUseCase: ListAvailableSurveysUseCase - @Mock lateinit var parseSurveyQrCodeUseCase: ParseSurveyQrCodeUseCase + @Mock lateinit var parseSurveyQrCodeUseCase: SurveyQrCodeParser @Mock lateinit var qrCodeScanner: GmsQrCodeScanner @Mock lateinit var removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase @Mock lateinit var userRepository: UserRepositoryInterface @@ -129,7 +128,7 @@ class SurveySelectorViewModelTest : BaseHiltTest() { fun `scanQrCodeAndActivateSurvey activates parsed survey`() = runWithTestDispatcher { createViewModel() val payload = "https://groundplatform.org/android/survey/xyz" - whenever(qrCodeScanner.scan()).thenReturn(QrScanResult.Success(payload)) + whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Success(payload)) whenever(parseSurveyQrCodeUseCase(payload)).thenReturn("xyz") whenever(activateSurveyUseCase("xyz")).thenReturn(true) @@ -142,7 +141,7 @@ class SurveySelectorViewModelTest : BaseHiltTest() { @Test fun `scanQrCodeAndActivateSurvey emits invalid event for bad payload`() = runWithTestDispatcher { createViewModel() - whenever(qrCodeScanner.scan()).thenReturn(QrScanResult.Success("not a url")) + whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Success("not a url")) whenever(parseSurveyQrCodeUseCase("not a url")).thenReturn(null) viewModel.events.test { @@ -155,7 +154,7 @@ class SurveySelectorViewModelTest : BaseHiltTest() { @Test fun `scanQrCodeAndActivateSurvey is silent on cancellation`() = runWithTestDispatcher { createViewModel() - whenever(qrCodeScanner.scan()).thenReturn(QrScanResult.Cancelled) + whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Cancelled) viewModel.events.test { viewModel.scanQrCodeAndActivateSurvey() @@ -167,7 +166,7 @@ class SurveySelectorViewModelTest : BaseHiltTest() { fun `scanQrCodeAndActivateSurvey surfaces scanner error`() = runWithTestDispatcher { createViewModel() val error = RuntimeException("camera unavailable") - whenever(qrCodeScanner.scan()).thenReturn(QrScanResult.Error(error)) + whenever(qrCodeScanner.scan()).thenReturn(GmsQrCodeScanner.Result.Error(error)) viewModel.events.test { viewModel.scanQrCodeAndActivateSurvey() diff --git a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/SurveyQrCodeParser.kt similarity index 76% rename from core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt rename to core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/SurveyQrCodeParser.kt index 74f4b05a3d..7ba08e865c 100644 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/util/SurveyQrCodeParser.kt @@ -13,13 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.domain.usecases.survey +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 ParseSurveyQrCodeUseCase(private val deepLinkHost: String, private val deepLinkPath: String) { +class SurveyQrCodeParser(private val deepLinkHost: String, private val deepLinkPath: String) { operator fun invoke(payload: String): String? { val regex = Regex( diff --git a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt similarity index 53% rename from core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt rename to core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt index a1ea4d0959..3c357e8f3d 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/usecases/survey/ParseSurveyQrCodeUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt @@ -13,75 +13,76 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.groundplatform.domain.usecases.survey +package org.groundplatform.domain.util import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -class ParseSurveyQrCodeUseCaseTest { +class SurveyQrCodeParserTest { - private val parseSurveyQrCode = ParseSurveyQrCodeUseCase("groundplatform.org", "/android/survey/") + private val parser = SurveyQrCodeParser("groundplatform.org", "/android/survey/") @Test fun `extracts survey id from canonical https url`() { - assertEquals("testId", parseSurveyQrCode("https://groundplatform.org/android/survey/testId")) + assertEquals("testId", parser("https://groundplatform.org/android/survey/testId")) } @Test fun `accepts http scheme`() { - assertEquals("testId", parseSurveyQrCode("http://groundplatform.org/android/survey/testId")) + assertEquals("testId", parser("http://groundplatform.org/android/survey/testId")) } @Test fun `tolerates surrounding whitespace`() { assertEquals( "testId", - parseSurveyQrCode(" https://groundplatform.org/android/survey/testId "), + parser(" https://groundplatform.org/android/survey/testId "), ) } @Test fun `tolerates trailing slash`() { - assertEquals("testId", parseSurveyQrCode("https://groundplatform.org/android/survey/testId/")) + assertEquals("testId", parser("https://groundplatform.org/android/survey/testId/")) } @Test fun `rejects wrong host`() { - assertNull(parseSurveyQrCode("https://example.com/android/survey/testId")) + assertNull(parser("https://example.com/android/survey/testId")) } @Test fun `rejects wrong path`() { - assertNull(parseSurveyQrCode("https://groundplatform.org/web/survey/testId")) + assertNull(parser("https://groundplatform.org/web/survey/testId")) } @Test fun `rejects empty id`() { - assertNull(parseSurveyQrCode("https://groundplatform.org/android/survey/")) + assertNull(parser("https://groundplatform.org/android/survey/")) } @Test fun `rejects non-url payload`() { - assertNull(parseSurveyQrCode("just a plain string")) + assertNull(parser("just a plain string")) } @Test fun `rejects id with disallowed characters`() { - assertNull(parseSurveyQrCode("https://groundplatform.org/android/survey/abc 123")) + assertNull(parser("https://groundplatform.org/android/survey/abc 123")) } @Test fun `host is configurable`() { - val parser = ParseSurveyQrCodeUseCase("staging.example.org", "/android/survey/") - assertEquals("testId", parser("https://staging.example.org/android/survey/testId")) - assertNull(parser("https://groundplatform.org/android/survey/testId")) + 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 parser = ParseSurveyQrCodeUseCase("groundplatform.org", "/web/survey/") - assertEquals("testId", parser("https://groundplatform.org/web/survey/testId")) - assertNull(parser("https://groundplatform.org/android/survey/testId")) + val customParser = SurveyQrCodeParser("groundplatform.org", "/web/survey/") + assertEquals("testId", customParser("https://groundplatform.org/web/survey/testId")) + assertNull(customParser("https://groundplatform.org/android/survey/testId")) } } + From ebd2b9b956adc3c9e16835706b30e7b9d1289ef2 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 4 May 2026 15:30:03 +0200 Subject: [PATCH 07/11] update GmsQrCodeScanner.kt to handle cancellations properly --- .../android/system/GmsQrCodeScanner.kt | 38 ++++++++++--------- .../domain/model/qrscanner/QrScanResult.kt | 26 ------------- 2 files changed, 21 insertions(+), 43 deletions(-) delete mode 100644 core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt diff --git a/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt b/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt index e1bacb8d85..ae125e85d0 100644 --- a/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt +++ b/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt @@ -16,34 +16,38 @@ package org.groundplatform.android.system import android.content.Context -import com.google.mlkit.common.MlKitException import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.tasks.await -import org.groundplatform.domain.model.qrscanner.QrScanResult +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine @Singleton class GmsQrCodeScanner @Inject constructor(@ApplicationContext private val context: Context) { - suspend fun scan(): QrScanResult { + suspend fun scan(): Result = suspendCancellableCoroutine { coroutine -> val options = GmsBarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() - return runCatching { GmsBarcodeScanning.getClient(context, options).startScan().await() } - .fold( - onSuccess = { barcode -> - barcode.rawValue?.let(QrScanResult::Success) ?: QrScanResult.Cancelled - }, - onFailure = { error -> - if (error is MlKitException && error.errorCode == MlKitException.CODE_SCANNER_CANCELLED) { - QrScanResult.Cancelled - } else { - QrScanResult.Error(error) - } - }, - ) + GmsBarcodeScanning.getClient(context, options) + .startScan() + .addOnSuccessListener { barcode -> + coroutine.resume(barcode.rawValue?.let(Result::Success) ?: Result.Cancelled) + } + .addOnCanceledListener { coroutine.resume(Result.Cancelled) } + .addOnFailureListener { e -> 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/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt b/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt deleted file mode 100644 index 1131dafab3..0000000000 --- a/core/domain/src/commonMain/kotlin/org/groundplatform/domain/model/qrscanner/QrScanResult.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.model.qrscanner -sealed interface QrScanResult { - /** The scanner returned a decoded payload. */ - data class Success(val text: String) : QrScanResult - - /** The user dismissed the scanner without a successful scan. */ - data object Cancelled : QrScanResult - - /** The scan failed (camera unavailable, module install failure, etc.). */ - data class Error(val cause: Throwable) : QrScanResult -} From 42f3415fef05c8e712ee3f246f8541c384af7d86 Mon Sep 17 00:00:00 2001 From: andreia Date: Mon, 4 May 2026 15:34:56 +0200 Subject: [PATCH 08/11] fix formatting --- .../groundplatform/domain/util/SurveyQrCodeParserTest.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index 3c357e8f3d..9e6df63237 100644 --- a/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt +++ b/core/domain/src/commonTest/kotlin/org/groundplatform/domain/util/SurveyQrCodeParserTest.kt @@ -35,10 +35,7 @@ class SurveyQrCodeParserTest { @Test fun `tolerates surrounding whitespace`() { - assertEquals( - "testId", - parser(" https://groundplatform.org/android/survey/testId "), - ) + assertEquals("testId", parser(" https://groundplatform.org/android/survey/testId ")) } @Test @@ -85,4 +82,3 @@ class SurveyQrCodeParserTest { assertNull(customParser("https://groundplatform.org/android/survey/testId")) } } - From 7fd7bee4f5cd1adc27c0a9d291912c7d2a65d07a Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 5 May 2026 12:33:44 +0200 Subject: [PATCH 09/11] fix event not handled correctly if scanning an already active survey --- .../android/ui/surveyselector/SurveySelectorScreen.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 43f659fdc7..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 @@ -32,7 +32,10 @@ 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 @@ -62,9 +65,9 @@ fun SurveySelectorScreen( ) { 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.errorType) From e351d118c7603d4c6a1138c010053d23fc0c77df Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 5 May 2026 12:34:01 +0200 Subject: [PATCH 10/11] add logs to GmsQrCodeScanner --- .../android/system/GmsQrCodeScanner.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt b/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt index ae125e85d0..1306a591bf 100644 --- a/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt +++ b/app/src/main/java/org/groundplatform/android/system/GmsQrCodeScanner.kt @@ -24,6 +24,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber @Singleton class GmsQrCodeScanner @Inject constructor(@ApplicationContext private val context: Context) { @@ -34,10 +35,17 @@ class GmsQrCodeScanner @Inject constructor(@ApplicationContext private val conte 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 { coroutine.resume(Result.Cancelled) } - .addOnFailureListener { e -> coroutine.resume(Result.Error(e)) } + .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 { From c0b8b97baca9228d95fe9a092e625659ede545b3 Mon Sep 17 00:00:00 2001 From: andreia Date: Tue, 5 May 2026 12:34:23 +0200 Subject: [PATCH 11/11] add tests --- .../surveyselector/SurveySelectorViewModel.kt | 7 +- .../android/system/GmsQrCodeScannerTest.kt | 125 ++++++++++++++++++ .../SurveySelectorViewModelTest.kt | 81 +++++++++--- 3 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/org/groundplatform/android/system/GmsQrCodeScannerTest.kt 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 894a914470..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 @@ -55,7 +55,7 @@ internal constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, listAvailableSurveysUseCase: ListAvailableSurveysUseCase, private val gmsQrCodeScanner: GmsQrCodeScanner, - private val parseSurveyQrCodeUseCase: SurveyQrCodeParser, + private val surveyQrCodeParser: SurveyQrCodeParser, private val removeOfflineSurveyUseCase: RemoveOfflineSurveyUseCase, private val userRepository: UserRepositoryInterface, savedStateHandle: SavedStateHandle, @@ -63,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) @@ -132,7 +132,7 @@ internal constructor( viewModelScope.launch { when (val result = gmsQrCodeScanner.scan()) { is GmsQrCodeScanner.Result.Success -> { - val surveyId = parseSurveyQrCodeUseCase(result.text) + val surveyId = surveyQrCodeParser(result.text) if (surveyId == null) { _events.send(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.InvalidQrCode)) } else { @@ -143,7 +143,6 @@ internal constructor( /* Nothing to do */ } is GmsQrCodeScanner.Result.Error -> { - Timber.e(result.cause, "QR scan failed") _events.send(SurveySelectorEvent.ShowError(result.cause.toSurveySelectorError())) } } 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 d48799e69b..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,11 +19,16 @@ 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 @@ -115,6 +120,35 @@ class SurveySelectorViewModelTest : BaseHiltTest() { } } + @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")) @@ -124,6 +158,19 @@ class SurveySelectorViewModelTest : BaseHiltTest() { viewModel.events.test { assertThat(awaitItem()).isEqualTo(SurveySelectorEvent.NavigateToHome) } } + @Test + fun `activateSurvey from deeplink shows error on failure`() = runWithTestDispatcher { + val savedState = SavedStateHandle(mapOf("surveyId" to "bad-id")) + val error = RuntimeException("activation failed") + whenever(activateSurveyUseCase("bad-id")).thenThrow(error) + createViewModel(savedStateHandle = savedState) + + viewModel.events.test { + assertThat(awaitItem()) + .isEqualTo(SurveySelectorEvent.ShowError(SurveySelectorEvent.ErrorType.Generic(error))) + } + } + @Test fun `scanQrCodeAndActivateSurvey activates parsed survey`() = runWithTestDispatcher { createViewModel() @@ -163,26 +210,30 @@ class SurveySelectorViewModelTest : BaseHiltTest() { } @Test - fun `scanQrCodeAndActivateSurvey surfaces scanner error`() = 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))) + 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 `activateSurvey from deeplink shows error on failure`() = runWithTestDispatcher { - val savedState = SavedStateHandle(mapOf("surveyId" to "bad-id")) - val error = RuntimeException("activation failed") - whenever(activateSurveyUseCase("bad-id")).thenThrow(error) - createViewModel(savedStateHandle = savedState) + 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))) }