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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2025 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.model

import org.groundplatform.android.model.job.Job

/**
* Checks if a survey is usable for data collection. A survey is considered usable if it has at
* least one predefined LOI or at least one job that allows ad hoc LOIs.
*/
fun Survey.isUsable(loiCount: Int = 0): Boolean {
// If there are predefined LOIs, the survey is usable
if (loiCount > 0) {
return true
}

// If there's at least one job that allows ad hoc LOIs, the survey is usable
return jobs.any { job ->
job.strategy == Job.DataCollectionStrategy.AD_HOC ||
job.strategy == Job.DataCollectionStrategy.MIXED
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ constructor(
*/
suspend fun hasValidLois(surveyId: String): Boolean = localLoiStore.getLoiCount(surveyId) > 0

/** Returns the count of valid (not deleted) [LocationOfInterest] for the given [surveyId]. */
suspend fun getLoiCount(surveyId: String): Int = localLoiStore.getLoiCount(surveyId)

/** Returns a flow of all valid (not deleted) [LocationOfInterest] in the given [Survey]. */
fun getValidLois(survey: Survey): Flow<Set<LocationOfInterest>> =
localLoiStore.getValidLois(survey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.groundplatform.android.R
import org.groundplatform.android.databinding.SurveySelectorFragBinding
import org.groundplatform.android.model.SurveyListItem
import org.groundplatform.android.ui.common.AbstractFragment
Expand Down Expand Up @@ -72,6 +73,10 @@ class SurveySelectorFragment : AbstractFragment(), BackPressListener {
dismissProgressDialog()
ephemeralPopups.ErrorPopup().unknownError()
}
is UiState.UnusableSurvey -> {
dismissProgressDialog()
ephemeralPopups.ErrorPopup().show(R.string.unusable_survey_error)
}
is UiState.NavigateToHome -> {
findNavController().navigate(HomeScreenFragmentDirections.showHomeScreen())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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.android.usecases.survey.UnusableSurveyException
import timber.log.Timber

/** Represents view state and behaviors of the survey selector dialog. */
Expand Down Expand Up @@ -106,7 +107,13 @@ internal constructor(
onSurveyActivationFailed()
}
},
onFailure = { onSurveyActivationFailed(it) },
onFailure = { error ->
if (error is UnusableSurveyException) {
onUnusableSurvey()
} else {
onSurveyActivationFailed(error)
}
},
)
}
}
Expand All @@ -117,6 +124,12 @@ internal constructor(
_uiState.emit(UiState.NavigateToHome)
}

private suspend fun onUnusableSurvey() {
Timber.e("Survey is unusable: no predefined LOIs and no ad hoc jobs")
surveyActivationInProgress = false
_uiState.emit(UiState.UnusableSurvey)
}

private suspend fun onSurveyActivationFailed(error: Throwable? = null) {
Timber.e(error, "Failed to activate survey")
surveyActivationInProgress = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,8 @@ sealed class UiState {
/** Represents that there was an error while activating surveys. */
data object Error : UiState()

/** Represents that the selected survey has no predefined LOIs and no ad hoc jobs. */
data object UnusableSurvey : UiState()

data object NavigateToHome : UiState()
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package org.groundplatform.android.usecases.survey

import javax.inject.Inject
import org.groundplatform.android.data.sync.SurveySyncWorker
import org.groundplatform.android.model.isUsable
import org.groundplatform.android.repository.LocationOfInterestRepository
import org.groundplatform.android.repository.SurveyRepository

/**
Expand All @@ -31,25 +33,37 @@ import org.groundplatform.android.repository.SurveyRepository
class ActivateSurveyUseCase
@Inject
constructor(
private val locationOfInterestRepository: LocationOfInterestRepository,
private val makeSurveyAvailableOffline: MakeSurveyAvailableOfflineUseCase,
private val surveyRepository: SurveyRepository,
) {

/**
* @return `true` if the survey was successfully activated or was already active, otherwise false.
* @throws UnusableSurveyException if the survey has no predefined LOIs and no ad hoc jobs.
*/
suspend operator fun invoke(surveyId: String): Boolean {
if (surveyRepository.isSurveyActive(surveyId)) {
// Do nothing if survey is already active.
return true
}

surveyRepository.getOfflineSurvey(surveyId)
?: makeSurveyAvailableOffline(surveyId)
?: error("Survey $surveyId not found in remote db")
val survey =
surveyRepository.getOfflineSurvey(surveyId)
?: makeSurveyAvailableOffline(surveyId)
?: error("Survey $surveyId not found in remote db")

// Check if the survey has predefined LOIs or ad hoc jobs
val loiCount = locationOfInterestRepository.getLoiCount(surveyId)
if (!survey.isUsable(loiCount)) {
throw UnusableSurveyException("Survey $surveyId has no predefined LOIs and no ad hoc jobs")
}

surveyRepository.activateSurvey(surveyId)

return surveyRepository.isSurveyActive(surveyId)
}
}

/** Exception thrown when a survey has no predefined LOIs and no ad hoc jobs. */
class UnusableSurveyException(message: String) : Exception(message)
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
<!-- Shown below fields that are required but left empty (TBD). -->
<string name="required_task">This field is required</string>

<!--
SURVEY ERRORS
-->
<string name="unusable_survey_error">This survey cannot be used because it has no predefined locations and does not allow adding new locations.</string>

<!--
LOCATION (GPS/CELL/WIFI) UPDATE ERROR MESSAGES
-->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2025 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.model

import com.google.common.truth.Truth.assertThat
import org.groundplatform.android.model.job.Job
import org.groundplatform.android.proto.Survey
import org.junit.Test

class SurveyExtensionsTest {

@Test
fun `isUsable returns true when survey has predefined LOIs`() {
val survey = createSurvey(emptyMap())

// Test with predefined LOIs
assertThat(survey.isUsable(loiCount = 5)).isTrue()
}

@Test
fun `isUsable returns true when survey has ad hoc jobs`() {
val jobWithAdHoc =
Job(id = "job1", name = "Job 1", strategy = Job.DataCollectionStrategy.AD_HOC)

val survey = createSurvey(mapOf("job1" to jobWithAdHoc))

// Test with no LOIs but with ad hoc job
assertThat(survey.isUsable(loiCount = 0)).isTrue()
}

@Test
fun `isUsable returns true when survey has mixed jobs`() {
val jobWithMixed = Job(id = "job1", name = "Job 1", strategy = Job.DataCollectionStrategy.MIXED)

val survey = createSurvey(mapOf("job1" to jobWithMixed))

// Test with no LOIs but with mixed job
assertThat(survey.isUsable(loiCount = 0)).isTrue()
}

@Test
fun `isUsable returns false when survey has no predefined LOIs and only predefined jobs`() {
val jobWithPredefined =
Job(id = "job1", name = "Job 1", strategy = Job.DataCollectionStrategy.PREDEFINED)

val survey = createSurvey(mapOf("job1" to jobWithPredefined))

// Test with no LOIs and only predefined job
assertThat(survey.isUsable(loiCount = 0)).isFalse()
}

@Test
fun `isUsable returns false when survey has no predefined LOIs and no jobs`() {
val survey = createSurvey(emptyMap())

// Test with no LOIs and no jobs
assertThat(survey.isUsable(loiCount = 0)).isFalse()
}

private fun createSurvey(jobMap: Map<String, Job>) =
Survey(
id = "survey1",
title = "Test Survey",
description = "Test Description",
jobMap = jobMap,
generalAccess = Survey.GeneralAccess.RESTRICTED,
)
}
Loading