Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Move validation to QuestionViewModel
  • Loading branch information
grzesiek2010 committed Jan 23, 2026
commit d7c7789bbed940bbd1b8c560ff2b1041c2fb0ee4
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package org.odk.collect.android.activities

import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras
import org.javarosa.core.model.actions.recordaudio.RecordAudioActions
import org.javarosa.core.model.instance.TreeReference
import org.odk.collect.android.entities.EntitiesRepositoryProvider
Expand All @@ -28,6 +28,7 @@ import org.odk.collect.android.utilities.FormsRepositoryProvider
import org.odk.collect.android.utilities.InstancesRepositoryProvider
import org.odk.collect.android.utilities.MediaUtils
import org.odk.collect.android.utilities.SavepointsRepositoryProvider
import org.odk.collect.android.widgets.viewmodels.QuestionViewModel
import org.odk.collect.async.Scheduler
import org.odk.collect.audiorecorder.recording.AudioRecorder
import org.odk.collect.location.LocationClient
Expand All @@ -39,7 +40,6 @@ import org.odk.collect.settings.SettingsProvider
import java.util.function.BiConsumer

class FormEntryViewModelFactory(
owner: SavedStateRegistryOwner,
private val mode: String?,
private val sessionId: String,
private val scheduler: Scheduler,
Expand All @@ -60,13 +60,9 @@ class FormEntryViewModelFactory(
private val htmlPrinter: HtmlPrinter,
private val instancesDataService: InstancesDataService,
private val changeLockProvider: ChangeLockProvider
) : AbstractSavedStateViewModelFactory(owner, null) {
) : ViewModelProvider.Factory {

override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val projectId = projectsDataService.requireCurrentProject().uuid

return when (modelClass) {
Expand All @@ -81,7 +77,7 @@ class FormEntryViewModelFactory(

FormSaveViewModel::class.java -> {
FormSaveViewModel(
handle,
extras.createSavedStateHandle(),
System::currentTimeMillis,
DiskFormSaver(),
mediaUtils,
Expand Down Expand Up @@ -152,6 +148,8 @@ class FormEntryViewModelFactory(

PrinterWidgetViewModel::class.java -> PrinterWidgetViewModel(scheduler, qrCodeCreator, htmlPrinter)

QuestionViewModel::class.java -> QuestionViewModel(scheduler, formSessionRepository, sessionId)

else -> throw IllegalArgumentException()
} as T
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ public void onCreate(Bundle savedInstanceState) {
sessionId = savedInstanceState.getString(KEY_SESSION_ID);
}

viewModelFactory = new FormEntryViewModelFactory(this,
viewModelFactory = new FormEntryViewModelFactory(
getIntent().getStringExtra(FormOpeningMode.FORM_MODE_KEY),
sessionId,
scheduler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,6 @@ public class FormEntryViewModel extends ViewModel implements SelectChoiceLoader
private final MutableLiveData<Triple<FormIndex, FormIndex, FailedValidationResult>> currentIndex = new MutableLiveData<>(null);
private final MutableLiveData<Consumable<ValidationResult>>
validationResult = new MutableLiveData<>(new Consumable<>(null));
private final MutableLiveData<Consumable<ValidationResult>>
constraintValidationResult = new MutableLiveData<>(new Consumable<>(null));
@NonNull
private final FormSessionRepository formSessionRepository;
private final String sessionId;
Expand Down Expand Up @@ -131,10 +129,6 @@ public LiveData<Consumable<ValidationResult>> getValidationResult() {
return validationResult;
}

public LiveData<Consumable<ValidationResult>> getConstraintValidationResult() {
return constraintValidationResult;
}

public NonNullLiveData<Boolean> isLoading() {
return worker.isWorking();
}
Expand Down Expand Up @@ -397,13 +391,6 @@ public void exit() {
changeLocks.getFormsLock().unlock(FORM_ENTRY_TOKEN);
}

public void validateAnswerConstraint(FormIndex index, IAnswerData answer) {
worker.immediate(() -> {
ValidationResult result = formController.validateAnswerConstraint(index, answer);
constraintValidationResult.postValue(new Consumable<>(result));
});
}

public void validateForm() {
worker.immediate(
() -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ class FormHierarchyFragmentHostActivity : LocalizedActivity() {
private val sessionId by lazy { intent.getStringExtra(EXTRA_SESSION_ID)!! }
private val viewModelFactory by lazy {
FormEntryViewModelFactory(
this,
FormOpeningMode.EDIT_SAVED,
sessionId,
scheduler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import org.javarosa.core.model.FormIndex
import org.javarosa.core.model.data.IAnswerData
import org.javarosa.form.api.FormEntryPrompt
import org.odk.collect.android.R
import org.odk.collect.android.databinding.WidgetAnswerDialogLayoutBinding
import org.odk.collect.android.formentry.FormEntryViewModel
import org.odk.collect.android.widgets.viewmodels.QuestionViewModel
import org.odk.collect.androidshared.ui.FragmentFactoryBuilder
import org.odk.collect.material.MaterialFullScreenDialogFragment
import kotlin.reflect.KClass
Expand All @@ -26,18 +28,18 @@ abstract class WidgetAnswerDialogFragment<T : Fragment>(
) : MaterialFullScreenDialogFragment() {

private val formEntryViewModel: FormEntryViewModel by activityViewModels { viewModelFactory }
private val questionViewModel: QuestionViewModel by viewModels { viewModelFactory }
private val prompt: FormEntryPrompt by lazy {
formEntryViewModel.getQuestionPrompt(requireArguments().getSerializable(ARG_FORM_INDEX) as FormIndex)
}
protected val constraintValidationResult by lazy {
formEntryViewModel.constraintValidationResult
questionViewModel.constraintValidationResult
}

abstract fun onCreateFragment(prompt: FormEntryPrompt): T

override fun onAttach(context: Context) {
super.onAttach(context)

childFragmentManager.fragmentFactory = FragmentFactoryBuilder()
.forClass(type) { onCreateFragment(prompt) }
.build()
Expand Down Expand Up @@ -73,7 +75,7 @@ abstract class WidgetAnswerDialogFragment<T : Fragment>(
}

fun onValidate(answer: IAnswerData?) {
formEntryViewModel.validateAnswerConstraint(prompt.index, answer)
questionViewModel.validate(prompt.index, answer)
}

fun onAnswer(answer: IAnswerData?) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.odk.collect.android.widgets.viewmodels

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.javarosa.core.model.FormIndex
import org.javarosa.core.model.data.IAnswerData
import org.odk.collect.android.formentry.FormSession
import org.odk.collect.android.formentry.FormSessionRepository
import org.odk.collect.android.javarosawrapper.FormController
import org.odk.collect.android.javarosawrapper.ValidationResult
import org.odk.collect.androidshared.data.Consumable
import org.odk.collect.androidshared.livedata.LiveDataUtils
import org.odk.collect.async.Scheduler

class QuestionViewModel(
private val scheduler: Scheduler,
formSessionRepository: FormSessionRepository,
sessionId: String
) : ViewModel() {
private val _constraintValidationResult: MutableLiveData<Consumable<ValidationResult>> = MutableLiveData()
val constraintValidationResult: LiveData<Consumable<ValidationResult>> = _constraintValidationResult
private var formController: FormController? = null
private var formSessionObserver = LiveDataUtils.observe(
formSessionRepository.get(sessionId)
) { formSession: FormSession ->
formController = formSession.formController
}

fun validate(index: FormIndex, answer: IAnswerData?) {
scheduler.immediate {
formController?.validateAnswerConstraint(index, answer)?.let {
_constraintValidationResult.postValue(Consumable(it))
}
}
}

override fun onCleared() {
formSessionObserver.cancel()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.core.Is.is;
import static org.javarosa.core.model.Constants.CONTROL_SELECT_ONE;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
Expand Down Expand Up @@ -456,26 +455,6 @@ public void answerQuestion_savesAnswerToFormController() {
assertThat(formController.getAnswer(formIndex.getReference()).getValue(), equalTo("answer"));
}

@Test
public void validateAnswerConstraint_updatesConstraintValidationResult_ifIsIsFailedValidationResult() {
FormDef formDef = mock();
when(formDef.evaluateConstraint(any(), any())).thenReturn(false);
formController.setFormDef(formDef);

TreeReference reference = new TreeReference();
reference.add("blah", TreeReference.INDEX_UNBOUND);
FormIndex formIndex = new FormIndex(null, 1, 1, reference);
FormEntryPrompt prompt = new MockFormEntryPromptBuilder().build();
formController.setPrompt(formIndex, prompt);

FailedValidationResult failedValidationResult = new FailedValidationResult(formIndex, 0, null, org.odk.collect.strings.R.string.invalid_answer_error);
formController.setFailedConstraint(failedValidationResult);

viewModel.validateAnswerConstraint(formIndex, new StringData("answer"));
scheduler.flush(true);
assertThat(viewModel.getConstraintValidationResult().getValue().getValue(), equalTo(failedValidationResult));
}

@Test
public void answerQuestion_whenQuestionIsAutoAdvance_movesForward() {
TreeReference reference = new TreeReference();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.odk.collect.android.support.CollectHelpers
import org.odk.collect.android.support.MockFormEntryPromptBuilder
import org.odk.collect.android.widgets.utilities.AdditionalAttributes.INCREMENTAL
import org.odk.collect.android.widgets.utilities.WidgetAnswerDialogFragment.Companion.ARG_FORM_INDEX
import org.odk.collect.android.widgets.viewmodels.QuestionViewModel
import org.odk.collect.androidshared.data.Consumable
import org.odk.collect.androidshared.ui.FragmentFactoryBuilder
import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule
Expand All @@ -50,12 +51,19 @@ class GeoPolyDialogFragmentTest {
)
private val formEntryViewModel = mock<FormEntryViewModel> {
on { getQuestionPrompt(prompt.index) } doReturn prompt
}

private val questionViewModel = mock<QuestionViewModel> {
on { constraintValidationResult } doReturn constraintValidationResult
}

private val viewModelFactory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return formEntryViewModel as T
return when (modelClass) {
FormEntryViewModel::class.java -> formEntryViewModel as T
QuestionViewModel::class.java -> questionViewModel as T
else -> throw IllegalArgumentException()
}
}
}

Expand Down Expand Up @@ -333,7 +341,7 @@ class GeoPolyDialogFragmentTest {
)
}

verify(formEntryViewModel).validateAnswerConstraint(prompt.index, geoTraceOf(answer))
verify(questionViewModel).validate(prompt.index, geoTraceOf(answer))
}

@Test
Expand All @@ -354,7 +362,7 @@ class GeoPolyDialogFragmentTest {
)
}

verify(formEntryViewModel).validateAnswerConstraint(prompt.index, geoShapeOf(answer))
verify(questionViewModel).validate(prompt.index, geoShapeOf(answer))
}

@Test
Expand Down Expand Up @@ -390,7 +398,7 @@ class GeoPolyDialogFragmentTest {
)
}

verify(formEntryViewModel, never()).validateAnswerConstraint(prompt.index, geoTraceOf(answer))
verify(questionViewModel, never()).validate(prompt.index, geoTraceOf(answer))
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.odk.collect.android.widgets.viewmodels

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.javarosa.core.model.FormDef
import org.javarosa.core.model.FormIndex
import org.javarosa.core.model.data.StringData
import org.javarosa.core.model.instance.TreeReference
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.odk.collect.android.formentry.support.InMemFormSessionRepository
import org.odk.collect.android.javarosawrapper.FailedValidationResult
import org.odk.collect.android.javarosawrapper.FakeFormController
import org.odk.collect.android.support.MockFormEntryPromptBuilder
import org.odk.collect.strings.R
import org.odk.collect.testshared.FakeScheduler
import org.odk.collect.testshared.getOrAwaitValue

@RunWith(AndroidJUnit4::class)
class QuestionViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()

private val scheduler: FakeScheduler = FakeScheduler()
private val startingIndex = FormIndex(null, 0, 0, TreeReference())
private val formController = FakeFormController(startingIndex, mock())
private val formSessionRepository = InMemFormSessionRepository().apply {
set("blah", formController, mock())
}
private val viewModel = QuestionViewModel(scheduler, formSessionRepository, "blah")

@Test
fun `validate updates constraintValidationResult`() {
val formDef: FormDef = mock()
whenever(formDef.evaluateConstraint(any(), any())).thenReturn(false)
formController.setFormDef(formDef)

val reference = TreeReference()
reference.add("blah", TreeReference.INDEX_UNBOUND)
val formIndex = FormIndex(null, 1, 1, reference)
val prompt = MockFormEntryPromptBuilder().build()
formController.setPrompt(formIndex, prompt)

val failedValidationResult =
FailedValidationResult(formIndex, 0, null, R.string.invalid_answer_error)
formController.setFailedConstraint(failedValidationResult)

viewModel.validate(formIndex, StringData("answer"))
assertThat(
viewModel.constraintValidationResult.getOrAwaitValue(scheduler).value,
equalTo(failedValidationResult)
)
}
}