diff --git a/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt b/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt index d94b853f1ef..c4064673a4b 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt @@ -51,6 +51,7 @@ class ImageRegionSelectionInteractionView @JvmOverloads constructor( private lateinit var onRegionClicked: OnClickableAreaClickedListener private lateinit var imageUrl: String private lateinit var clickableAreas: List + private lateinit var lastSelectedRegion: ImageWithRegions.LabeledRegion /** * Sets the URL for the image & initiates loading it. This is intended to be called via @@ -66,6 +67,11 @@ class ImageRegionSelectionInteractionView @JvmOverloads constructor( maybeInitializeClickableAreas() } + fun setLastSelectedRegion(lastSelectedRegion: ImageWithRegions.LabeledRegion) { + this.lastSelectedRegion = lastSelectedRegion + maybeInitializeClickableAreas() + } + fun setClickableAreas(clickableAreas: List) { this.clickableAreas = clickableAreas // Resets the backgrounds for all regions if any have been loaded. This ensures the backgrounds @@ -120,6 +126,9 @@ class ImageRegionSelectionInteractionView @JvmOverloads constructor( clickableAreas ) areasImage.addRegionViews() + if (::lastSelectedRegion.isInitialized && lastSelectedRegion.hasRegion()) { + areasImage.toggleRegion(lastSelectedRegion, view = null) + } performAttachment(areasImage) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt index e55b81ef35d..cb8c2704491 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.HelpIndex +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler @@ -19,9 +20,15 @@ import org.oppia.android.app.player.state.listener.PreviousResponsesHeaderClickL import org.oppia.android.app.player.state.listener.ReturnToTopicNavigationButtonListener import org.oppia.android.app.player.state.listener.ShowHintAvailabilityListener import org.oppia.android.app.player.state.listener.SubmitNavigationButtonListener +import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.getStringFromBundle +import org.oppia.android.util.extensions.putProto import javax.inject.Inject +private const val STATE_FRAGMENT_RAW_USER_ANSWER_KEY = "StateFragment.raw_user_answer" +private const val STATE_FRAGMENT_ARE_PREVIOUS_RESPONSES_HEADER_EXPANDED_KEY = + "StateFragment.are_previous_responses_header_expanded" + /** Fragment that represents the current state of an exploration. */ class StateFragment : InjectableFragment(), @@ -79,12 +86,20 @@ class StateFragment : val storyId = arguments!!.getStringFromBundle(STATE_FRAGMENT_STORY_ID_ARGUMENT_KEY)!! val explorationId = arguments!!.getStringFromBundle(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)!! + val rawUserAnswer = savedInstanceState?.getProto( + STATE_FRAGMENT_RAW_USER_ANSWER_KEY, RawUserAnswer.getDefaultInstance() + ) ?: RawUserAnswer.getDefaultInstance() + val arePreviousResponsesExpanded = + savedInstanceState?.getBoolean(STATE_FRAGMENT_ARE_PREVIOUS_RESPONSES_HEADER_EXPANDED_KEY) + ?: false return stateFragmentPresenter.handleCreateView( inflater, container, internalProfileId, topicId, storyId, + rawUserAnswer, + arePreviousResponsesExpanded, explorationId ) } @@ -129,6 +144,18 @@ class StateFragment : stateFragmentPresenter.revealHint(hintIndex) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putProto( + STATE_FRAGMENT_RAW_USER_ANSWER_KEY, + stateFragmentPresenter.getRawUserAnswer() + ) + outState.putBoolean( + STATE_FRAGMENT_ARE_PREVIOUS_RESPONSES_HEADER_EXPANDED_KEY, + stateFragmentPresenter.getArePreviousResponsesExpanded() + ) + } + fun revealSolution() = stateFragmentPresenter.revealSolution() fun dismissConceptCard() = stateFragmentPresenter.dismissConceptCard() diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt index 1f9a6116436..a95037267e0 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt @@ -24,6 +24,7 @@ import org.oppia.android.app.model.CheckpointState import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.State import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.audio.AudioButtonListener @@ -47,6 +48,9 @@ import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.ExplorationHtmlParserEntityType +import org.oppia.android.util.platformparameter.EnableHintBulbAnimation +import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention +import org.oppia.android.util.platformparameter.PlatformParameterValue import org.oppia.android.util.system.OppiaClock import javax.inject.Inject @@ -71,6 +75,10 @@ class StateFragmentPresenter @Inject constructor( private val storyProgressController: StoryProgressController, private val oppiaLogger: OppiaLogger, @DefaultResourceBucketName private val resourceBucketName: String, + @EnableInteractionConfigChangeStateRetention + private val isConfigChangeStateRetentionEnabled: PlatformParameterValue, + @EnableHintBulbAnimation + private val isHintBulbAnimationEnabled: PlatformParameterValue, private val assemblerBuilderFactory: StatePlayerRecyclerViewAssembler.Builder.Factory, private val splitScreenManager: SplitScreenManager, private val oppiaClock: OppiaClock @@ -105,6 +113,8 @@ class StateFragmentPresenter @Inject constructor( internalProfileId: Int, topicId: String, storyId: String, + rawUserAnswer: RawUserAnswer, + arePreviousResponsesExpanded: Boolean, explorationId: String ): View? { profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() @@ -118,7 +128,13 @@ class StateFragmentPresenter @Inject constructor( /* attachToRoot= */ false ) recyclerViewAssembler = createRecyclerViewAssembler( - assemblerBuilderFactory.create(resourceBucketName, entityType, profileId), + assemblerBuilderFactory.create( + resourceBucketName, + entityType, + profileId, + rawUserAnswer, + arePreviousResponsesExpanded + ), binding.congratulationsTextView, binding.congratulationsTextConfettiView, binding.fullScreenConfettiView @@ -164,7 +180,7 @@ class StateFragmentPresenter @Inject constructor( fun handleAnswerReadyForSubmission(answer: UserAnswer) { // An interaction has indicated that an answer is ready for submission. - handleSubmitAnswer(answer) + handleSubmitAnswer(answer, canSubmitAnswer = true) } fun onContinueButtonClicked() { @@ -208,9 +224,7 @@ class StateFragmentPresenter @Inject constructor( fun handleKeyboardAction() { hideKeyboard() - if (viewModel.getCanSubmitAnswer().get() == true) { - handleSubmitAnswer(viewModel.getPendingAnswer(recyclerViewAssembler::getPendingAnswerHandler)) - } + handleSubmitAnswer(viewModel.getPendingAnswer(recyclerViewAssembler::getPendingAnswerHandler)) } fun onHintAvailable(helpIndex: HelpIndex, isCurrentStatePendingState: Boolean) { @@ -261,6 +275,11 @@ class StateFragmentPresenter @Inject constructor( subscribeToHintSolution(explorationProgressController.submitSolutionIsRevealed()) } + /** Returns whether previously submitted wrong answers are currently expanded. */ + fun getArePreviousResponsesExpanded(): Boolean { + return recyclerViewAssembler.arePreviousResponsesExpanded + } + private fun getStateViewModel(): StateViewModel { return viewModelProvider.getForFragment(fragment, StateViewModel::class.java) } @@ -358,6 +377,9 @@ class StateFragmentPresenter @Inject constructor( private fun subscribeToAnswerOutcome( answerOutcomeResultLiveData: LiveData> ) { + if (viewModel.getCanSubmitAnswer().get() == true) { + recyclerViewAssembler.resetRawUserAnswer() + } val answerOutcomeLiveData = getAnswerOutcome(answerOutcomeResultLiveData) answerOutcomeLiveData.observe( fragment, @@ -401,8 +423,15 @@ class StateFragmentPresenter @Inject constructor( } } - private fun handleSubmitAnswer(answer: UserAnswer) { - subscribeToAnswerOutcome(explorationProgressController.submitAnswer(answer).toLiveData()) + private fun handleSubmitAnswer( + answer: UserAnswer, + canSubmitAnswer: Boolean = viewModel.getCanSubmitAnswer().get() ?: false + ) { + // This check seems to avoid a crash on configuration change when attempting to resubmit answers + // after encountering a submit-time error, but it's also more correct to keep it. + if (canSubmitAnswer) { + subscribeToAnswerOutcome(explorationProgressController.submitAnswer(answer).toLiveData()) + } } fun dismissConceptCard() { @@ -451,6 +480,13 @@ class StateFragmentPresenter @Inject constructor( /** Returns the checkpoint state for the current exploration. */ fun getExplorationCheckpointState() = explorationCheckpointState + /** Returns the [RawUserAnswer] representing the user's current pending answer. */ + fun getRawUserAnswer(): RawUserAnswer { + return if (isConfigChangeStateRetentionEnabled.value) { + viewModel.getRawUserAnswer(recyclerViewAssembler::getPendingAnswerHandler) + } else RawUserAnswer.getDefaultInstance() + } + private fun markExplorationCompleted() { storyProgressController.recordCompletedChapter( profileId, @@ -494,6 +530,8 @@ class StateFragmentPresenter @Inject constructor( private fun setHintOpenedAndUnRevealed(isHintUnrevealed: Boolean) { viewModel.setHintOpenedAndUnRevealedVisibility(isHintUnrevealed) + if (!isHintBulbAnimationEnabled.value) return + if (isHintUnrevealed) { val hintBulbAnimation = AnimationUtils.loadAnimation( context, diff --git a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt index bac406d7fda..f4a4f2e684d 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -25,6 +25,7 @@ import org.oppia.android.app.model.EphemeralState.StateTypeCase import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.StringList import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.UserAnswer @@ -144,7 +145,9 @@ class StatePlayerRecyclerViewAssembler private constructor( backgroundCoroutineDispatcher: CoroutineDispatcher, private val hasConversationView: Boolean, private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController + private val translationController: TranslationController, + private var rawUserAnswer: RawUserAnswer, + arePreviousResponsesExpanded: Boolean ) : HtmlParser.CustomOppiaTagActionListener { /** * A list of view models corresponding to past view models that are hidden by default. These are @@ -158,7 +161,8 @@ class StatePlayerRecyclerViewAssembler private constructor( * Whether the previously submitted wrong answers should be expanded. This value is intentionally * not retained upon configuration changes since the user can just re-expand the list. */ - private var hasPreviousResponsesExpanded: Boolean = false + var arePreviousResponsesExpanded: Boolean = arePreviousResponsesExpanded + private set private val lifecycleSafeTimerFactory = LifecycleSafeTimerFactory(backgroundCoroutineDispatcher) @@ -300,6 +304,13 @@ class StatePlayerRecyclerViewAssembler private constructor( return Pair(conversationPendingItemList, extraInteractionPendingItemList) } + /** + * Resets rawUserAnswer to it's default instance. + */ + fun resetRawUserAnswer() { + rawUserAnswer = RawUserAnswer.getDefaultInstance() + } + private fun addInteractionForPendingState( pendingItemList: MutableList, interaction: Interaction, @@ -311,13 +322,17 @@ class StatePlayerRecyclerViewAssembler private constructor( pendingItemList += interactionViewModelFactory.create( gcsEntityId, hasConversationView, + rawUserAnswer, interaction, fragment as InteractionAnswerReceiver, fragment as InteractionAnswerErrorOrAvailabilityCheckReceiver, hasPreviousButton, isSplitView.get()!!, writtenTranslationContext - ) + ).also { + // Ensure that potential errors are re-detected in cases of configuration changes. + (it as? InteractionAnswerHandler)?.checkPendingAnswerError(rawUserAnswer.lastErrorCategory) + } } private fun addContentItem( @@ -353,7 +368,7 @@ class StatePlayerRecyclerViewAssembler private constructor( PreviousResponsesHeaderViewModel( answersAndResponses.size - 1, hasConversationView, - ObservableBoolean(hasPreviousResponsesExpanded), + ObservableBoolean(arePreviousResponsesExpanded), fragment as PreviousResponsesHeaderClickListener, isSplitView.get()!!, resourceHandler @@ -364,7 +379,7 @@ class StatePlayerRecyclerViewAssembler private constructor( } // Only add previous answers if current responses are expanded, or if collapsing is disabled. val showPreviousAnswers = !playerFeatureSet.wrongAnswerCollapsing || - hasPreviousResponsesExpanded + arePreviousResponsesExpanded for (answerAndResponse in answersAndResponses.take(answersAndResponses.size - 1)) { if (playerFeatureSet.pastAnswerSupport) { // Earlier answers can't be correct (since otherwise new answers wouldn't be able to be @@ -442,7 +457,7 @@ class StatePlayerRecyclerViewAssembler private constructor( } // Ensure the header matches the updated state. headerModel.isExpanded.set(expandPreviousAnswers) - hasPreviousResponsesExpanded = expandPreviousAnswers + arePreviousResponsesExpanded = expandPreviousAnswers } /** @@ -454,7 +469,7 @@ class StatePlayerRecyclerViewAssembler private constructor( check(playerFeatureSet.wrongAnswerCollapsing) { "Cannot collapse previous answers for assembler that doesn't support wrong answer collapsing" } - hasPreviousResponsesExpanded = false + arePreviousResponsesExpanded = false } /** @@ -889,7 +904,9 @@ class StatePlayerRecyclerViewAssembler private constructor( private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController, private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, - private val singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + private val singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory, + private val rawUserAnswer: RawUserAnswer, + private val arePreviousResponsesExpanded: Boolean ) { private val adapterBuilder: BindableAdapter.MultiTypeBuilder) -> InteractionAnswerHandler? + ): RawUserAnswer { + return retrieveAnswerHandler(getAnswerItemList())?.getRawUserAnswer() + ?: RawUserAnswer.getDefaultInstance() + } + private fun getPendingAnswerWithoutError( answerHandler: InteractionAnswerHandler? ): UserAnswer? { diff --git a/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt b/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt index cb8251b3295..0eecb3d7516 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt @@ -1,5 +1,7 @@ package org.oppia.android.app.player.state.answerhandling +import org.oppia.android.app.model.AnswerErrorCategory +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer /** @@ -22,10 +24,18 @@ interface InteractionAnswerHandler { return null } - /** Return the current answer that is ready for handling. */ + /** Returns the pending answer awaiting submission by the user. */ fun getPendingAnswer(): UserAnswer? { return null } + + /** + * Returns a raw representation of the current answer entered by the user which is used to retain + * state on configuration changes. + */ + fun getRawUserAnswer(): RawUserAnswer { + return RawUserAnswer.getDefaultInstance() + } } /** @@ -35,11 +45,3 @@ interface InteractionAnswerHandler { interface InteractionAnswerReceiver { fun onAnswerReadyForSubmission(answer: UserAnswer) } - -/** Categories of errors that can be inferred from a pending answer. */ -enum class AnswerErrorCategory { - /** Corresponds to errors that may be found while the user is trying to input an answer. */ - REAL_TIME, - /** Corresponds to errors that may be found only when a user tries to submit an answer. */ - SUBMIT_TIME -} diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt index d79be12f560..f43189bbc7c 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.fragment.app.Fragment import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver @@ -24,6 +25,7 @@ private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." class ContinueInteractionViewModel private constructor( private val interactionAnswerReceiver: InteractionAnswerReceiver, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, val hasPreviousButton: Boolean, val previousNavigationButtonListener: PreviousNavigationButtonListener, val isSplitView: Boolean, @@ -52,6 +54,7 @@ class ContinueInteractionViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -62,6 +65,7 @@ class ContinueInteractionViewModel private constructor( return ContinueInteractionViewModel( interactionAnswerReceiver, hasConversationView, + rawUserAnswer, hasPreviousButton, fragment as PreviousNavigationButtonListener, isSplitView, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt index 1509d9a295b..70de7b8d7df 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt @@ -3,10 +3,12 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.databinding.Observable import androidx.databinding.ObservableField import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.ListOfSetsOfHtmlStrings import org.oppia.android.app.model.ListOfSetsOfTranslatableHtmlContentIds +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds import org.oppia.android.app.model.StringList import org.oppia.android.app.model.SubtitledHtml @@ -27,6 +29,7 @@ import javax.inject.Inject class DragAndDropSortInteractionViewModel private constructor( val entityId: String, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, @@ -56,7 +59,13 @@ class DragAndDropSortInteractionViewModel private constructor( } private val _choiceItems: MutableList = - computeChoiceItems(contentIdHtmlMap, choiceSubtitledHtmls, this, resourceHandler) + computeChoiceItems( + contentIdHtmlMap, + choiceSubtitledHtmls, + this, + resourceHandler, + rawUserAnswer.listOfSetsOfTranslatableHtmlContentIds + ) val choiceItems: List = _choiceItems @@ -129,6 +138,15 @@ class DragAndDropSortInteractionViewModel private constructor( this@DragAndDropSortInteractionViewModel.writtenTranslationContext }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + val htmlContentIds = _choiceItems.map { it.htmlContent } + listOfSetsOfTranslatableHtmlContentIds = + ListOfSetsOfTranslatableHtmlContentIds.newBuilder().apply { + addAllContentIdLists(htmlContentIds) + }.build() + lastErrorCategory = AnswerErrorCategory.NO_ERROR + }.build() + /** Returns an HTML list containing all of the HTML string elements as items in the list. */ private fun convertItemsToAnswer(htmlItems: List): ListOfSetsOfHtmlStrings { return ListOfSetsOfHtmlStrings.newBuilder() @@ -198,6 +216,7 @@ class DragAndDropSortInteractionViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -208,6 +227,7 @@ class DragAndDropSortInteractionViewModel private constructor( return DragAndDropSortInteractionViewModel( entityId, hasConversationView, + rawUserAnswer, interaction, answerErrorReceiver, isSplitView, @@ -223,17 +243,24 @@ class DragAndDropSortInteractionViewModel private constructor( contentIdHtmlMap: Map, choiceStrings: List, dragAndDropSortInteractionViewModel: DragAndDropSortInteractionViewModel, - resourceHandler: AppLanguageResourceHandler + resourceHandler: AppLanguageResourceHandler, + listOfSetsOfTranslatableHtmlContentIds: ListOfSetsOfTranslatableHtmlContentIds ): MutableList { - return choiceStrings.mapIndexed { index, subtitledHtml -> + val selectedChoices = + if (listOfSetsOfTranslatableHtmlContentIds.contentIdListsList.isEmpty()) { + choiceStrings.map { subtitledHtml -> + SetOfTranslatableHtmlContentIds.newBuilder().apply { + addContentIds( + TranslatableHtmlContentId.newBuilder().setContentId(subtitledHtml.contentId) + ) + } + } + } else listOfSetsOfTranslatableHtmlContentIds.contentIdListsList + return selectedChoices.mapIndexed { index, contentId -> DragDropInteractionContentViewModel( contentIdHtmlMap = contentIdHtmlMap, htmlContent = SetOfTranslatableHtmlContentIds.newBuilder().apply { - addContentIds( - TranslatableHtmlContentId.newBuilder().apply { - contentId = subtitledHtml.contentId - } - ) + addAllContentIds(contentId.contentIdsList) }.build(), itemIndex = index, listSize = choiceStrings.size, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt index 54ee1566a38..2eb86fe9fd7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -5,12 +5,13 @@ import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.FractionParsingUiError -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -22,6 +23,7 @@ import javax.inject.Inject /** [StateItemViewModel] for the fraction input interaction. */ class FractionInteractionViewModel private constructor( interaction: Interaction, + rawUserAnswer: RawUserAnswer, val hasConversationView: Boolean, val isSplitView: Boolean, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -30,12 +32,12 @@ class FractionInteractionViewModel private constructor( private val translationController: TranslationController ) : StateItemViewModel(ViewType.FRACTION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null - var answerText: CharSequence = "" + var answerText: CharSequence = rawUserAnswer.textualAnswer var isAnswerAvailable = ObservableField(false) var errorMessage = ObservableField("") - val hintText: CharSequence = deriveHintText(interaction) private val fractionParser = FractionParser() + private var currentErrorCategory = AnswerErrorCategory.NO_ERROR init { val callback: Observable.OnPropertyChangedCallback = @@ -65,25 +67,35 @@ class FractionInteractionViewModel private constructor( /** It checks the pending error for the current fraction input, and correspondingly updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { if (answerText.isNotEmpty()) { - when (category) { + pendingAnswerError = when (category) { AnswerErrorCategory.REAL_TIME -> { - pendingAnswerError = - FractionParsingUiError.createFromParsingError( - fractionParser.getRealTimeAnswerError(answerText.toString()) - ).getErrorMessageFromStringRes(resourceHandler) + FractionParsingUiError.createFromParsingError( + fractionParser.getRealTimeAnswerError(answerText.toString()) + ).getErrorMessageFromStringRes(resourceHandler) } AnswerErrorCategory.SUBMIT_TIME -> { - pendingAnswerError = - FractionParsingUiError.createFromParsingError( - fractionParser.getSubmitTimeError(answerText.toString()) - ).getErrorMessageFromStringRes(resourceHandler) + FractionParsingUiError.createFromParsingError( + fractionParser.getSubmitTimeError(answerText.toString()) + ).getErrorMessageFromStringRes(resourceHandler) } + AnswerErrorCategory.ANSWER_ERROR_CATEGORY_UNSPECIFIED, AnswerErrorCategory.UNRECOGNIZED, + AnswerErrorCategory.NO_ERROR -> null } + currentErrorCategory = if (pendingAnswerError == null) { + AnswerErrorCategory.NO_ERROR + } else category errorMessage.set(pendingAnswerError) } return pendingAnswerError } + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + textualAnswer = answerText.toString() + lastErrorCategory = currentErrorCategory + } + }.build() + fun getAnswerTextWatcher(): TextWatcher { return object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { @@ -136,6 +148,7 @@ class FractionInteractionViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -145,6 +158,7 @@ class FractionInteractionViewModel private constructor( ): StateItemViewModel { return FractionInteractionViewModel( interaction, + rawUserAnswer, hasConversationView, isSplitView, answerErrorReceiver, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt index 47b6a5e4569..aca0fbb5213 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt @@ -3,10 +3,13 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.ClickOnImage -import org.oppia.android.app.model.ImageWithRegions +import org.oppia.android.app.model.ImageWithRegions.LabeledRegion import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.Point2d +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver @@ -23,6 +26,7 @@ import javax.inject.Inject class ImageRegionSelectionInteractionViewModel private constructor( val entityId: String, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, val isSplitView: Boolean, @@ -32,7 +36,7 @@ class ImageRegionSelectionInteractionViewModel private constructor( InteractionAnswerHandler, OnClickableAreaClickedListener { var answerText: CharSequence = "" - val selectableRegions: List by lazy { + val selectableRegions: List by lazy { val schemaObject = interaction.customizationArgsMap["imageAndRegions"] schemaObject?.customSchemaValue?.imageWithRegions?.labelRegionsList ?: listOf() } @@ -41,8 +45,8 @@ class ImageRegionSelectionInteractionViewModel private constructor( val schemaObject = interaction.customizationArgsMap["imageAndRegions"] schemaObject?.customSchemaValue?.imageWithRegions?.imagePath ?: "" } - val isAnswerAvailable = ObservableField(false) + val lastSelectedRegion = ObservableField(rawUserAnswer.imageRegionSelection) init { val callback: Observable.OnPropertyChangedCallback = @@ -57,7 +61,7 @@ class ImageRegionSelectionInteractionViewModel private constructor( isAnswerAvailable.addOnPropertyChangedCallback(callback) } - override fun onClickableAreaTouched(region: RegionClickedEvent) { + override fun onClickableAreaTouched(region: RegionClickedEvent, coordinates: Point2d) { when (region) { is DefaultRegionClickedEvent -> { answerText = "" @@ -83,12 +87,20 @@ class ImageRegionSelectionInteractionViewModel private constructor( this@ImageRegionSelectionInteractionViewModel.writtenTranslationContext }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + imageRegionSelection = selectableRegions.find { it.label == answerText.toString() } + } + lastErrorCategory = AnswerErrorCategory.NO_ERROR + }.build() + private fun parseClickOnImage(answerTextString: String): ClickOnImage { val region = selectableRegions.find { it.label == answerTextString } - return ClickOnImage.newBuilder() - // The object supports multiple regions in an answer, but neither web nor Android supports this. - .addClickedRegions(region?.label ?: "") - .build() + return ClickOnImage.newBuilder().apply { + // The object supports multiple regions in an answer, but neither web nor Android + // supports this. + addClickedRegions(region?.label ?: "") + }.build() } /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ @@ -98,6 +110,7 @@ class ImageRegionSelectionInteractionViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -108,6 +121,7 @@ class ImageRegionSelectionInteractionViewModel private constructor( return ImageRegionSelectionInteractionViewModel( entityId, hasConversationView, + rawUserAnswer, interaction, answerErrorReceiver, isSplitView, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt index 77687c70a5d..b505e432fff 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt @@ -6,14 +6,15 @@ import androidx.annotation.StringRes import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.MathEquation import org.oppia.android.app.model.MathExpression import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -59,6 +60,7 @@ import org.oppia.android.app.model.MathBinaryOperation.Operator as UnaryOperator class MathExpressionInteractionsViewModel private constructor( interaction: Interaction, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler, @@ -72,7 +74,7 @@ class MathExpressionInteractionsViewModel private constructor( * Defines the current answer text being entered by the learner. This is expected to be directly * bound to the corresponding edit text. */ - var answerText: CharSequence = "" + var answerText: CharSequence = rawUserAnswer.textualAnswer /** * Defines whether an answer is currently available to parse. This is expected to be directly @@ -89,6 +91,8 @@ class MathExpressionInteractionsViewModel private constructor( /** Specifies the text to show in the answer box when no text is entered. */ val hintText: CharSequence = deriveHintText(interaction) + private var currentErrorCategory = AnswerErrorCategory.NO_ERROR + private val allowedVariables = retrieveAllowedVariables(interaction) private val useFractionsForDivision = interaction.customizationArgsMap["useFractionForDivision"]?.boolValue ?: false @@ -140,6 +144,13 @@ class MathExpressionInteractionsViewModel private constructor( } }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + textualAnswer = answerText.toString() + } + lastErrorCategory = currentErrorCategory + }.build() + override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { if (answerText.isNotEmpty()) { pendingAnswerError = when (category) { @@ -150,7 +161,12 @@ class MathExpressionInteractionsViewModel private constructor( answerText.toString(), allowedVariables, resourceHandler ) } + AnswerErrorCategory.ANSWER_ERROR_CATEGORY_UNSPECIFIED, AnswerErrorCategory.UNRECOGNIZED, + AnswerErrorCategory.NO_ERROR -> null } + currentErrorCategory = if (pendingAnswerError == null) { + AnswerErrorCategory.NO_ERROR + } else category errorMessage.set(pendingAnswerError) } return pendingAnswerError @@ -225,6 +241,7 @@ class MathExpressionInteractionsViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -235,6 +252,7 @@ class MathExpressionInteractionsViewModel private constructor( return MathExpressionInteractionsViewModel( interaction, hasConversationView, + rawUserAnswer, answerErrorReceiver, writtenTranslationContext, resourceHandler, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt index 70deff47224..8a4b77f6e59 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt @@ -4,12 +4,13 @@ import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToNumberParser -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -19,16 +20,18 @@ import javax.inject.Inject /** [StateItemViewModel] for the numeric input interaction. */ class NumericInputViewModel private constructor( val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.NUMERIC_INPUT_INTERACTION), InteractionAnswerHandler { - var answerText: CharSequence = "" + var answerText: CharSequence = rawUserAnswer.textualAnswer private var pendingAnswerError: String? = null val errorMessage = ObservableField("") var isAnswerAvailable = ObservableField(false) private val stringToNumberParser: StringToNumberParser = StringToNumberParser() + private var currentErrorCategory = AnswerErrorCategory.NO_ERROR init { val callback: Observable.OnPropertyChangedCallback = @@ -40,6 +43,7 @@ class NumericInputViewModel private constructor( ) } } + errorMessage.addOnPropertyChangedCallback(callback) isAnswerAvailable.addOnPropertyChangedCallback(callback) } @@ -54,8 +58,13 @@ class NumericInputViewModel private constructor( AnswerErrorCategory.SUBMIT_TIME -> stringToNumberParser.getSubmitTimeError(answerText.toString()) .getErrorMessageFromStringRes(resourceHandler) + AnswerErrorCategory.ANSWER_ERROR_CATEGORY_UNSPECIFIED, AnswerErrorCategory.UNRECOGNIZED, + AnswerErrorCategory.NO_ERROR -> null } } + currentErrorCategory = if (pendingAnswerError == null) { + AnswerErrorCategory.NO_ERROR + } else category errorMessage.set(pendingAnswerError) return pendingAnswerError } @@ -90,6 +99,13 @@ class NumericInputViewModel private constructor( } }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + textualAnswer = answerText.toString() + } + lastErrorCategory = currentErrorCategory + }.build() + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ class FactoryImpl @Inject constructor( private val resourceHandler: AppLanguageResourceHandler @@ -97,6 +113,7 @@ class NumericInputViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -106,6 +123,7 @@ class NumericInputViewModel private constructor( ): StateItemViewModel { return NumericInputViewModel( hasConversationView, + rawUserAnswer, answerErrorReceiver, isSplitView, writtenTranslationContext, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt index 749151c4c40..e50bfbd7a90 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt @@ -5,12 +5,13 @@ import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToRatioParser -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -24,6 +25,7 @@ import javax.inject.Inject class RatioExpressionInputInteractionViewModel private constructor( interaction: Interaction, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, val isSplitView: Boolean, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, private val writtenTranslationContext: WrittenTranslationContext, @@ -31,9 +33,10 @@ class RatioExpressionInputInteractionViewModel private constructor( private val translationController: TranslationController ) : StateItemViewModel(ViewType.RATIO_EXPRESSION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null - var answerText: CharSequence = "" + var answerText: CharSequence = rawUserAnswer.textualAnswer var isAnswerAvailable = ObservableField(false) var errorMessage = ObservableField("") + private var currentErrorCategory = AnswerErrorCategory.NO_ERROR val hintText: CharSequence = deriveHintText(interaction) private val stringToRatioParser: StringToRatioParser = StringToRatioParser() @@ -50,8 +53,10 @@ class RatioExpressionInputInteractionViewModel private constructor( ) } } + errorMessage.addOnPropertyChangedCallback(callback) isAnswerAvailable.addOnPropertyChangedCallback(callback) + checkPendingAnswerError(AnswerErrorCategory.REAL_TIME) } override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { @@ -67,21 +72,33 @@ class RatioExpressionInputInteractionViewModel private constructor( } }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + textualAnswer = answerText.toString() + } + lastErrorCategory = currentErrorCategory + }.build() + /** It checks the pending error for the current ratio input, and correspondingly updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { if (answerText.isNotEmpty()) { - when (category) { - AnswerErrorCategory.REAL_TIME -> - pendingAnswerError = - stringToRatioParser.getRealTimeAnswerError(answerText.toString()) - .getErrorMessageFromStringRes(resourceHandler) - AnswerErrorCategory.SUBMIT_TIME -> - pendingAnswerError = - stringToRatioParser.getSubmitTimeError( - answerText.toString(), - numberOfTerms = numberOfTerms - ).getErrorMessageFromStringRes(resourceHandler) + pendingAnswerError = when (category) { + AnswerErrorCategory.REAL_TIME -> { + stringToRatioParser.getRealTimeAnswerError(answerText.toString()) + .getErrorMessageFromStringRes(resourceHandler) + } + AnswerErrorCategory.SUBMIT_TIME -> { + stringToRatioParser.getSubmitTimeError( + answerText.toString(), + numberOfTerms = numberOfTerms + ).getErrorMessageFromStringRes(resourceHandler) + } + AnswerErrorCategory.ANSWER_ERROR_CATEGORY_UNSPECIFIED, AnswerErrorCategory.UNRECOGNIZED, + AnswerErrorCategory.NO_ERROR -> null } + currentErrorCategory = if (pendingAnswerError == null) { + AnswerErrorCategory.NO_ERROR + } else category errorMessage.set(pendingAnswerError) } return pendingAnswerError @@ -135,6 +152,7 @@ class RatioExpressionInputInteractionViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -145,6 +163,7 @@ class RatioExpressionInputInteractionViewModel private constructor( return RatioExpressionInputInteractionViewModel( interaction, hasConversationView, + rawUserAnswer, isSplitView, answerErrorReceiver, writtenTranslationContext, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt index 21976f2a971..4474bc57cca 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt @@ -3,8 +3,11 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.databinding.Observable import androidx.databinding.ObservableField import androidx.databinding.ObservableList +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.ItemSelectionRawAnswer +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.TranslatableHtmlContentId @@ -27,6 +30,7 @@ enum class SelectionItemInputType { class SelectionInteractionViewModel private constructor( val entityId: String, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, @@ -51,7 +55,9 @@ class SelectionInteractionViewModel private constructor( interaction.customizationArgsMap["maxAllowableSelectionCount"]?.signedInt ?: minAllowableSelectionCount } + private val selectedItems: MutableList = mutableListOf() + val choiceItems: ObservableList = computeChoiceItems(choiceSubtitledHtmls, hasConversationView, this) @@ -67,6 +73,13 @@ class SelectionInteractionViewModel private constructor( ) } } + if (rawUserAnswer.itemSelection.selectedIndexesList.isNotEmpty()) { + rawUserAnswer.itemSelection.selectedIndexesList.forEach { index -> + selectedItems += index + updateIsAnswerAvailable() + choiceItems[index].isAnswerSelected.set(true) + } + } isAnswerAvailable.addOnPropertyChangedCallback(callback) } @@ -98,6 +111,13 @@ class SelectionInteractionViewModel private constructor( writtenTranslationContext = translationContext }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + itemSelection = ItemSelectionRawAnswer.newBuilder().apply { + addAllSelectedIndexes(selectedItems) + }.build() + lastErrorCategory = AnswerErrorCategory.NO_ERROR + }.build() + /** Returns an HTML list containing all of the HTML string elements as items in the list. */ private fun convertSelectedItemsToHtmlString(itemHtmls: Collection): String { return when (itemHtmls.size) { @@ -166,6 +186,7 @@ class SelectionInteractionViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -176,6 +197,7 @@ class SelectionInteractionViewModel private constructor( return SelectionInteractionViewModel( entityId, hasConversationView, + rawUserAnswer, interaction, answerErrorReceiver, isSplitView, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt index bce47761e47..034c6d6229e 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt @@ -1,6 +1,7 @@ package org.oppia.android.app.player.state.itemviewmodel import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -47,6 +48,7 @@ abstract class StateItemViewModel(val viewType: ViewType) : ObservableViewModel( fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt index 9395c23cdb7..5a71f22ee1f 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt @@ -5,8 +5,10 @@ import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver @@ -20,13 +22,14 @@ import javax.inject.Inject class TextInputViewModel private constructor( interaction: Interaction, val hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController ) : StateItemViewModel(ViewType.TEXT_INPUT_INTERACTION), InteractionAnswerHandler { - var answerText: CharSequence = "" + var answerText: CharSequence = rawUserAnswer.textualAnswer val hintText: CharSequence = deriveHintText(interaction) var isAnswerAvailable = ObservableField(false) @@ -73,6 +76,13 @@ class TextInputViewModel private constructor( } }.build() + override fun getRawUserAnswer(): RawUserAnswer = RawUserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + textualAnswer = answerText.toString() + } + lastErrorCategory = AnswerErrorCategory.NO_ERROR + }.build() + private fun deriveHintText(interaction: Interaction): CharSequence { // The subtitled unicode can apparently exist in the structure in two different formats. val placeholderUnicodeOption1 = @@ -102,6 +112,7 @@ class TextInputViewModel private constructor( override fun create( entityId: String, hasConversationView: Boolean, + rawUserAnswer: RawUserAnswer, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, @@ -112,6 +123,7 @@ class TextInputViewModel private constructor( return TextInputViewModel( interaction, hasConversationView, + rawUserAnswer, answerErrorReceiver, isSplitView, writtenTranslationContext, diff --git a/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt b/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt index 43c0374a19d..4e1c22ec9a9 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.app.model.Point2d import org.oppia.android.app.utility.ClickableAreasImage import org.oppia.android.app.utility.OnClickableAreaClickedListener import org.oppia.android.app.utility.RegionClickedEvent @@ -36,7 +37,7 @@ class ImageRegionSelectionTestFragment : InjectableFragment(), OnClickableAreaCl return imageRegionSelectionTestFragmentPresenter.handleCreateView(inflater, container) } - override fun onClickableAreaTouched(region: RegionClickedEvent) { - mockOnClickableAreaClickedListener.onClickableAreaTouched(region) + override fun onClickableAreaTouched(region: RegionClickedEvent, coordinates: Point2d) { + mockOnClickableAreaClickedListener.onClickableAreaTouched(region, coordinates) } } diff --git a/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt index 1fc9c3d6042..7e4cae9c37a 100644 --- a/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.customview.interaction.FractionInputInteractionView import org.oppia.android.app.customview.interaction.NumericInputInteractionView import org.oppia.android.app.customview.interaction.TextInputInteractionView +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.InputInteractionViewTestActivityParams import org.oppia.android.app.model.InputInteractionViewTestActivityParams.MathInteractionType.ALGEBRAIC_EXPRESSION import org.oppia.android.app.model.InputInteractionViewTestActivityParams.MathInteractionType.MATH_EQUATION @@ -18,10 +19,10 @@ import org.oppia.android.app.model.InputInteractionViewTestActivityParams.MathIn import org.oppia.android.app.model.InputInteractionViewTestActivityParams.MathInteractionType.NUMERIC_EXPRESSION import org.oppia.android.app.model.InputInteractionViewTestActivityParams.MathInteractionType.UNRECOGNIZED import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.SchemaObject import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.app.player.state.itemviewmodel.FractionInteractionViewModel @@ -158,6 +159,7 @@ class InputInteractionViewTestActivity : return create( entityId = "fake_entity_id", hasConversationView = false, + rawUserAnswer = RawUserAnswer.getDefaultInstance(), interaction = interaction, interactionAnswerReceiver = this@InputInteractionViewTestActivity, answerErrorReceiver = this@InputInteractionViewTestActivity, diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt index f92e0884e3d..bef59fd66b2 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -23,6 +24,11 @@ import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto import javax.inject.Inject +private const val QUESTION_PLAYER_FRAGMENT_RAW_USER_ANSWER_KEY = + "QuestionPlayerFragment.raw_user_answer" +private const val QUESTION_PLAYER_FRAGMENT_ARE_PREVIOUS_RESPONSES_HEADER_EXPANDED_KEY = + "QuestionPlayerFragment.are_previous_responses_header_expanded" + /** Fragment that contains all questions in Question Player. */ class QuestionPlayerFragment : InjectableFragment(), @@ -52,8 +58,17 @@ class QuestionPlayerFragment : val args = checkNotNull(arguments) { "Expected arguments to be passed to QuestionPlayerFragment" } + val rawUserAnswer = savedInstanceState?.getProto( + QUESTION_PLAYER_FRAGMENT_RAW_USER_ANSWER_KEY, RawUserAnswer.getDefaultInstance() + ) ?: RawUserAnswer.getDefaultInstance() + val arePreviousResponsesExpanded = + savedInstanceState?.getBoolean( + QUESTION_PLAYER_FRAGMENT_ARE_PREVIOUS_RESPONSES_HEADER_EXPANDED_KEY + ) ?: false val profileId = args.getProto(PROFILE_ID_ARGUMENT_KEY, ProfileId.getDefaultInstance()) - return questionPlayerFragmentPresenter.handleCreateView(inflater, container, profileId) + return questionPlayerFragmentPresenter.handleCreateView( + inflater, container, rawUserAnswer, arePreviousResponsesExpanded, profileId + ) } override fun onAnswerReadyForSubmission(answer: UserAnswer) { @@ -83,6 +98,18 @@ class QuestionPlayerFragment : override fun onHintAvailable(helpIndex: HelpIndex, isCurrentStatePendingState: Boolean) = questionPlayerFragmentPresenter.onHintAvailable(helpIndex, isCurrentStatePendingState) + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putProto( + QUESTION_PLAYER_FRAGMENT_RAW_USER_ANSWER_KEY, + questionPlayerFragmentPresenter.getRawUserAnswer() + ) + outState.putBoolean( + QUESTION_PLAYER_FRAGMENT_ARE_PREVIOUS_RESPONSES_HEADER_EXPANDED_KEY, + questionPlayerFragmentPresenter.getArePreviousResponsesExpanded() + ) + } + fun handleKeyboardAction() = questionPlayerFragmentPresenter.handleKeyboardAction() fun revealHint(hintIndex: Int) { diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt index 596ee8287d8..9a43a1ca276 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt @@ -18,6 +18,7 @@ import org.oppia.android.app.model.EphemeralQuestion import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.State import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.state.ConfettiConfig.MINI_CONFETTI_BURST @@ -35,6 +36,8 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.gcsresource.QuestionResourceBucketName +import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention +import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject /** The presenter for [QuestionPlayerFragment]. */ @@ -46,6 +49,8 @@ class QuestionPlayerFragmentPresenter @Inject constructor( private val questionAssessmentProgressController: QuestionAssessmentProgressController, private val oppiaLogger: OppiaLogger, @QuestionResourceBucketName private val resourceBucketName: String, + @EnableInteractionConfigChangeStateRetention + private val isConfigChangeStateRetentionEnabled: PlatformParameterValue, private val assemblerBuilderFactory: StatePlayerRecyclerViewAssembler.Builder.Factory, private val splitScreenManager: SplitScreenManager ) { @@ -68,6 +73,8 @@ class QuestionPlayerFragmentPresenter @Inject constructor( fun handleCreateView( inflater: LayoutInflater, container: ViewGroup?, + rawUserAnswer: RawUserAnswer, + arePreviousResponsesExpanded: Boolean, profileId: ProfileId ): View? { binding = QuestionPlayerFragmentBinding.inflate( @@ -77,9 +84,15 @@ class QuestionPlayerFragmentPresenter @Inject constructor( ) recyclerViewAssembler = createRecyclerViewAssembler( - assemblerBuilderFactory.create(resourceBucketName, "skill", profileId), + assemblerBuilderFactory.create( + resourceBucketName, + "skill", + profileId, + rawUserAnswer, + arePreviousResponsesExpanded + ), binding.congratulationsTextView, - binding.congratulationsTextConfettiView + binding.congratulationsTextConfettiView, ) binding.apply { @@ -111,6 +124,11 @@ class QuestionPlayerFragmentPresenter @Inject constructor( subscribeToHintSolution(questionAssessmentProgressController.submitSolutionIsRevealed()) } + /** Returns whether previously submitted wrong answers are currently expanded. */ + fun getArePreviousResponsesExpanded(): Boolean { + return recyclerViewAssembler.arePreviousResponsesExpanded + } + fun dismissConceptCard() { fragment.childFragmentManager.findFragmentByTag( CONCEPT_CARD_DIALOG_FRAGMENT_TAG @@ -121,7 +139,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( fun handleAnswerReadyForSubmission(answer: UserAnswer) { // An interaction has indicated that an answer is ready for submission. - handleSubmitAnswer(answer) + handleSubmitAnswer(answer, canSubmitAnswer = true) } fun onContinueButtonClicked() { @@ -251,14 +269,26 @@ class QuestionPlayerFragmentPresenter @Inject constructor( binding.endSessionBodyTextView.visibility = endSessionViewsVisibility } - private fun handleSubmitAnswer(answer: UserAnswer) { - subscribeToAnswerOutcome(questionAssessmentProgressController.submitAnswer(answer).toLiveData()) + private fun handleSubmitAnswer( + answer: UserAnswer, + canSubmitAnswer: Boolean = questionViewModel.getCanSubmitAnswer().get() ?: false + ) { + // This check seems to avoid a crash on configuration change when attempting to resubmit answers + // after encountering a submit-time error, but it's also more correct to keep it. + if (canSubmitAnswer) { + subscribeToAnswerOutcome( + questionAssessmentProgressController.submitAnswer(answer).toLiveData() + ) + } } /** This function listens to and processes the result of submitAnswer from QuestionAssessmentProgressController. */ private fun subscribeToAnswerOutcome( answerOutcomeResultLiveData: LiveData> ) { + if (questionViewModel.getCanSubmitAnswer().get() == true) { + recyclerViewAssembler.resetRawUserAnswer() + } val answerOutcomeLiveData = Transformations.map(answerOutcomeResultLiveData, ::processAnsweredQuestionOutcome) answerOutcomeLiveData.observe( @@ -389,4 +419,11 @@ class QuestionPlayerFragmentPresenter @Inject constructor( } } } + + /** Returns the [RawUserAnswer] representing the user's current pending answer. */ + fun getRawUserAnswer(): RawUserAnswer { + return if (isConfigChangeStateRetentionEnabled.value) { + questionViewModel.getRawUserAnswer(recyclerViewAssembler::getPendingAnswerHandler) + } else RawUserAnswer.getDefaultInstance() + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt index ee470eb7b66..aa3882ea748 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt @@ -4,8 +4,9 @@ import androidx.databinding.ObservableBoolean import androidx.databinding.ObservableField import androidx.databinding.ObservableList import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory +import org.oppia.android.app.model.RawUserAnswer import org.oppia.android.app.model.UserAnswer -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.android.app.translation.AppLanguageResourceHandler @@ -102,6 +103,14 @@ class QuestionPlayerViewModel @Inject constructor( } } + /** Retrieves the [RawUserAnswer] from the handler provided by [retrieveAnswerHandler]. */ + fun getRawUserAnswer( + retrieveAnswerHandler: (List) -> InteractionAnswerHandler? + ): RawUserAnswer { + return retrieveAnswerHandler(getAnswerItemList())?.getRawUserAnswer() + ?: RawUserAnswer.getDefaultInstance() + } + private fun getAnswerItemList(): List { return if (isSplitView.get() == true) { rightItemList diff --git a/app/src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt b/app/src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt index 69d599fd2de..4c18395e69c 100644 --- a/app/src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt +++ b/app/src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt @@ -10,6 +10,7 @@ import androidx.core.view.forEachIndexed import androidx.core.view.isVisible import org.oppia.android.R import org.oppia.android.app.model.ImageWithRegions +import org.oppia.android.app.model.Point2d import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView import org.oppia.android.app.shim.ViewBindingShim import kotlin.math.roundToInt @@ -48,7 +49,8 @@ class ClickableAreasImage( defaultRegionView.setBackgroundResource(R.drawable.selected_region_background) defaultRegionView.x = x defaultRegionView.y = y - listener.onClickableAreaTouched(DefaultRegionClickedEvent()) + val coordinates = Point2d.newBuilder().setX(x).setY(y).build() + listener.onClickableAreaTouched(DefaultRegionClickedEvent(), coordinates) } } @@ -87,32 +89,10 @@ class ClickableAreasImage( it.id != imageView.id && it.id != defaultRegionView.id }.forEach(parentView::removeView) clickableAreas.forEach { clickableArea -> - val imageRect = RectF( - getXCoordinate(clickableArea.region.area.upperLeft.x), - getYCoordinate(clickableArea.region.area.upperLeft.y), - getXCoordinate(clickableArea.region.area.lowerRight.x), - getYCoordinate(clickableArea.region.area.lowerRight.y) - ) - val layoutParams = FrameLayout.LayoutParams( - imageRect.width().roundToInt(), - imageRect.height().roundToInt() - ) - val newView = View(parentView.context) - // ClickableArea coordinates are not laid-out properly in RTL. The image region coordinates - // are from left-to-right with an upper left origin and touch coordinates from Android start - // from the right in RTL mode. Thus, to avoid this situation, force layout direction to LTR in - // all situations. - ViewCompat.setLayoutDirection(parentView, ViewCompat.LAYOUT_DIRECTION_LTR) - newView.layoutParams = layoutParams - newView.x = imageRect.left - newView.y = imageRect.top - newView.isClickable = true - newView.isFocusable = true - newView.isFocusableInTouchMode = true - newView.tag = clickableArea.label + val newView = createSelectableView(clickableArea) newView.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { - showOrHideRegion(newView, clickableArea) + toggleRegion(clickableArea, newView) } return@setOnTouchListener true } @@ -120,11 +100,9 @@ class ClickableAreasImage( // Make default region visibility gone when talkback enabled to avoid any accidental touch. defaultRegionView.isVisible = false newView.setOnClickListener { - showOrHideRegion(newView, clickableArea) + toggleRegion(clickableArea, newView) } } - newView.contentDescription = clickableArea.contentDescription - parentView.addView(newView) } // Ensure that the children views are properly computed. The specific flow below is recommended @@ -142,7 +120,11 @@ class ClickableAreasImage( } } - private fun showOrHideRegion(newView: View, clickableArea: ImageWithRegions.LabeledRegion) { + /** + * Toggles whether the clickable region corresponding to the provided [clickableArea] is visible + * and available to be clicked. + */ + fun toggleRegion(clickableArea: ImageWithRegions.LabeledRegion, view: View?) { resetRegionSelectionViews() listener.onClickableAreaTouched( NamedRegionClickedEvent( @@ -150,6 +132,35 @@ class ClickableAreasImage( clickableArea.contentDescription ) ) - newView.setBackgroundResource(R.drawable.selected_region_background) + + val affectedView = view ?: createSelectableView(clickableArea) + affectedView.setBackgroundResource(R.drawable.selected_region_background) + } + + private fun createSelectableView(clickableArea: ImageWithRegions.LabeledRegion): View { + val imageRect = RectF( + getXCoordinate(clickableArea.region.area.upperLeft.x), + getYCoordinate(clickableArea.region.area.upperLeft.y), + getXCoordinate(clickableArea.region.area.lowerRight.x), + getYCoordinate(clickableArea.region.area.lowerRight.y) + ) + val layoutParams = FrameLayout.LayoutParams( + imageRect.width().roundToInt(), + imageRect.height().roundToInt() + ) + val newView = View(parentView.context) + + ViewCompat.setLayoutDirection(parentView, ViewCompat.LAYOUT_DIRECTION_LTR) + newView.layoutParams = layoutParams + newView.x = imageRect.left + newView.y = imageRect.top + newView.isClickable = true + newView.isFocusable = true + newView.isFocusableInTouchMode = true + newView.tag = clickableArea.label + newView.contentDescription = clickableArea.contentDescription + parentView.addView(newView) + + return newView } } diff --git a/app/src/main/java/org/oppia/android/app/utility/OnClickableAreaClickedListener.kt b/app/src/main/java/org/oppia/android/app/utility/OnClickableAreaClickedListener.kt index 45909da4882..6c5b399da67 100644 --- a/app/src/main/java/org/oppia/android/app/utility/OnClickableAreaClickedListener.kt +++ b/app/src/main/java/org/oppia/android/app/utility/OnClickableAreaClickedListener.kt @@ -1,5 +1,7 @@ package org.oppia.android.app.utility +import org.oppia.android.app.model.Point2d + /** Listener for an image when it is clicked which have a [ClickableAreasImage] attached to the view. */ interface OnClickableAreaClickedListener { /** @@ -8,6 +10,10 @@ interface OnClickableAreaClickedListener { * For an specified region it will be called with [NamedRegionClickedEvent] with region name. * For an unspecified region it will be called with [DefaultRegionClickedEvent]. * + * @param coordinates the coordinates of unlabelled region */ - fun onClickableAreaTouched(region: RegionClickedEvent) + fun onClickableAreaTouched( + region: RegionClickedEvent, + coordinates: Point2d = Point2d.getDefaultInstance() + ) } diff --git a/app/src/main/res/layout/image_region_selection_interaction_item.xml b/app/src/main/res/layout/image_region_selection_interaction_item.xml index 375835975b7..8eeba0f5548 100644 --- a/app/src/main/res/layout/image_region_selection_interaction_item.xml +++ b/app/src/main/res/layout/image_region_selection_interaction_item.xml @@ -51,7 +51,8 @@ app:clickableAreas="@{viewModel.selectableRegions}" app:entityId="@{viewModel.entityId}" app:imageUrl="@{viewModel.imagePath}" - app:onRegionClicked="@{(region) -> viewModel.onClickableAreaTouched(region)}" + app:lastSelectedRegion="@{viewModel.lastSelectedRegion}" + app:onRegionClicked="@{(region, coordinates) -> viewModel.onClickableAreaTouched(region, coordinates)}" app:overlayView="@{interactionContainerFrameLayout}" /> (R.id.fraction_input_interaction_view) + assertThat(fractionInputInteraction.text.toString()).isEqualTo("34") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_numericInput_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + playThroughPrototypeState1() + playThroughPrototypeState2() + playThroughPrototypeState3() + playThroughPrototypeState4() + playThroughPrototypeState5() + // Entering text in Numeric Input. + typeNumericInput("121") + // Rotating device. + rotateToLandscape() + it.onActivity { + val numericInputInteractionView = + it.findViewById(R.id.numeric_input_interaction_view) + assertThat(numericInputInteractionView.text.toString()).isEqualTo("121") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_ratioInput_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + playThroughPrototypeState1() + playThroughPrototypeState2() + playThroughPrototypeState3() + playThroughPrototypeState4() + playThroughPrototypeState5() + playThroughPrototypeState6() + // Entering text in Ratio Input Interaction. + typeRatioExpression("3:5") + // Rotating device. + rotateToLandscape() + it.onActivity { + val ratioInputInteraction = + it.findViewById(R.id.ratio_input_interaction_view) + assertThat(ratioInputInteraction.text.toString()).isEqualTo("3:5") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_textInput_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + playThroughPrototypeState1() + playThroughPrototypeState2() + playThroughPrototypeState3() + playThroughPrototypeState4() + playThroughPrototypeState5() + playThroughPrototypeState6() + playThroughPrototypeState7() + // Enter text in Text Input Interaction. + typeTextInput("finnish") + // Rotating device. + rotateToLandscape() + it.onActivity { + val textInputInteraction = + it.findViewById(R.id.text_input_interaction_view) + assertThat(textInputInteraction.text.toString()).isEqualTo("finnish") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_selectMultipleChoiceOption_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + playThroughPrototypeState1() + playThroughPrototypeState2() + // Select answer. + selectMultipleChoiceOption(optionPosition = 2, expectedOptionText = "Eagle") + // Rotating device. + rotateToLandscape() + scrollToViewType(SELECTION_INTERACTION) + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = 2, + targetViewId = R.id.multiple_choice_radio_button + ) + ).check(matches(isChecked())) + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_selectItemSelectionCheckbox_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + playThroughPrototypeState1() + playThroughPrototypeState2() + playThroughPrototypeState3() + playThroughPrototypeState4() + // Select answer. + selectItemSelectionCheckbox(optionPosition = 0, expectedOptionText = "Red") + selectItemSelectionCheckbox(optionPosition = 2, expectedOptionText = "Green") + selectItemSelectionCheckbox(optionPosition = 3, expectedOptionText = "Blue") + // Rotating device. + rotateToLandscape() + scrollToViewType(SELECTION_INTERACTION) + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = 0, + targetViewId = R.id.item_selection_checkbox + ) + ).check(matches(isChecked())) + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = 2, + targetViewId = R.id.item_selection_checkbox + ) + ).check(matches(isChecked())) + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = 3, + targetViewId = R.id.item_selection_checkbox + ) + ).check(matches(isChecked())) + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_dragAndDrop_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + playThroughPrototypeState1() + playThroughPrototypeState2() + playThroughPrototypeState3() + playThroughPrototypeState4() + playThroughPrototypeState5() + playThroughPrototypeState6() + playThroughPrototypeState7() + playThroughPrototypeState8() + + // Drag and drop interaction without grouping. + dragAndDropItem(fromPosition = 0, toPosition = 3) + // Rotating device. + rotateToLandscape() + onView( + atPositionOnView( + recyclerViewId = R.id.drag_drop_interaction_recycler_view, + position = 3, + targetViewId = R.id.drag_drop_content_text_view + ) + ).check(matches(withText(containsString("0.35")))) + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_dragAndDrop_mergeFirstTwoItems_dragItem_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_4, shouldSavePartialProgress = false).use { + startPlayingExploration() + + mergeDragAndDropItems(position = 0) + dragAndDropItem(fromPosition = 0, toPosition = 2) + + // Rotating device. + rotateToLandscape() + scrollToViewType(DRAG_DROP_SORT_INTERACTION) + onView( + atPositionOnView( + recyclerViewId = R.id.drag_drop_interaction_recycler_view, + position = 2, + targetViewId = R.id.drag_drop_item_recyclerview + ) + ).check(matches(hasChildCount(2))) + onView( + atPositionOnView( + recyclerViewId = R.id.drag_drop_interaction_recycler_view, + position = 2, + targetViewId = R.id.drag_drop_item_recyclerview + ) + ).check { view, _ -> + val textView = view.findViewById(R.id.drag_drop_content_text_view) + assertThat(textView.text.toString(), containsString("a camera at the store")) + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_mathInteractions_numericExp_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_5, shouldSavePartialProgress = false).use { + startPlayingExploration() + // Enter text in numeric math input interaction. + typeNumericExpression("1+2") + // Rotating device. + rotateToLandscape() + it.onActivity { + val mathExpressionInteractionView = + it.findViewById( + R.id.math_expression_input_interaction_view + ) + assertThat(mathExpressionInteractionView.text.toString()).isEqualTo("1+2") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_mathInteractions_algExp_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_5, shouldSavePartialProgress = false).use { + startPlayingExploration() + playUpThroughMathInteractionExplorationState3() + // Enter text in numeric math input interaction. + typeAlgebraicExpression("x^2-x-2") + // Rotating device. + rotateToLandscape() + it.onActivity { + val mathExpressionInteractionView = + it.findViewById( + R.id.math_expression_input_interaction_view + ) + assertThat(mathExpressionInteractionView.text.toString()).isEqualTo("x^2-x-2") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_mathInteractions_mathEq_retainStateOnConfigurationChange() { + launchForExploration(TEST_EXPLORATION_ID_5, shouldSavePartialProgress = false).use { + startPlayingExploration() + playUpThroughMathInteractionExplorationState6() + + typeMathEquation("x^2-x-2=2y") + // Rotating device. + rotateToLandscape() + it.onActivity { + val mathExpressionInteractionView = + it.findViewById( + R.id.math_expression_input_interaction_view + ) + assertThat(mathExpressionInteractionView.text.toString()).isEqualTo("x^2-x-2=2y") + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_differentTextBasedInteractions_doesNotShareInitialState() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + clickContinueInteractionButton() + // Entering text in Fraction Input Interaction. + typeFractionText("1/2") + // Rotating device. + rotateToLandscape() + it.onActivity { + val fractionInputInteraction = + it.findViewById(R.id.fraction_input_interaction_view) + assertThat(fractionInputInteraction.text.toString()).isEqualTo("1/2") + } + clickSubmitAnswerButton() + clickContinueNavigationButton() + playThroughPrototypeState3() + playThroughPrototypeState4() + playThroughPrototypeState5() + + it.onActivity { + val numericInputInteractionView = + it.findViewById(R.id.numeric_input_interaction_view) + assertThat(numericInputInteractionView.text.toString()).isEmpty() + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_sameTextBasedInteractions_doesNotShareInitialState() { + launchForExploration(TEST_EXPLORATION_ID_5, shouldSavePartialProgress = false).use { + startPlayingExploration() + + typeNumericExpression("1+2") + rotateToLandscape() + clickSubmitAnswerButton() + clickContinueNavigationButton() + + it.onActivity { + val mathExpressionInteractionView = + it.findViewById( + R.id.math_expression_input_interaction_view + ) + assertThat(mathExpressionInteractionView.text.toString()).isEmpty() + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_checkPreviousHeaderExpanded_retainStateOnConfigChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + clickContinueInteractionButton() + + // Attempt to submit an wrong answer. + typeFractionText("1/4") + clickSubmitAnswerButton() + + // Attempt to submit a second wrong answer. + typeFractionText("1/4") + clickSubmitAnswerButton() + + // Expand previous response header. + scrollToViewType(PREVIOUS_RESPONSES_HEADER) + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.previous_response_header)).perform(click()) + testCoroutineDispatchers.runCurrent() + + // Rotating device. + rotateToLandscape() + + // Both failed answers should be showing. + onView(withId(R.id.state_recycler_view)) + .check( + matchesChildren(matcher = withId(R.id.submitted_answer_container), times = 2) + ) + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_onRealTimeError_retainStateOnConfigChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + clickContinueInteractionButton() + // Entering text in Fraction Input Interaction. + typeFractionText("k") + // Rotating device. + rotateToLandscape() + it.onActivity { + val fractionInputInteraction = + it.findViewById(R.id.fraction_input_interaction_view) + assertThat(fractionInputInteraction.text.toString()).isEqualTo("k") + } + onView(withId(R.id.fraction_input_error)).check(matches(isDisplayed())) + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_onRealTimeError_submitIncorrectAnswer_doesNotShareInitialState() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + clickContinueInteractionButton() + // Entering text in Fraction Input Interaction. + typeFractionText("k") + // Rotating device. + rotateToLandscape() + + scrollToViewType(FRACTION_INPUT_INTERACTION) + onView(withId(R.id.fraction_input_interaction_view)).perform(replaceText("12")) + testCoroutineDispatchers.runCurrent() + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + + it.onActivity { + val fractionInputInteraction = + it.findViewById(R.id.fraction_input_interaction_view) + assertThat(fractionInputInteraction.text.toString()).isEmpty() + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_onSubmitTimeError_retainStateOnConfigChange() { + launchForExploration(TEST_EXPLORATION_ID_5, shouldSavePartialProgress = false).use { + startPlayingExploration() + + typeNumericExpression("a") + onView(withId(R.id.submit_answer_button)).perform(click()) + + // Rotating device. + rotateToLandscape() + + it.onActivity { + val mathExpressionInteractionsView = + it.findViewById( + R.id.math_expression_input_interaction_view + ) + assertThat(mathExpressionInteractionsView.text.toString()).isEqualTo("a") + } + onView(withId(R.id.math_expression_input_error)).check(matches(isDisplayed())) + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_onSubmitTimeError_submitIncorrectAnswer_doesNotShareInitialState() { + launchForExploration(TEST_EXPLORATION_ID_5, shouldSavePartialProgress = false).use { + startPlayingExploration() + typeNumericExpression("12a") + onView(withId(R.id.submit_answer_button)).perform(click()) + + // Rotating device. + rotateToLandscape() + + scrollToViewType(NUMERIC_EXPRESSION_INPUT_INTERACTION) + onView(withId(R.id.math_expression_input_interaction_view)).perform(replaceText("12")) + testCoroutineDispatchers.runCurrent() + scrollToViewType(SUBMIT_ANSWER_BUTTON) + onView(withId(R.id.submit_answer_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + + it.onActivity { + val mathExpressionInteractionView = + it.findViewById( + R.id.math_expression_input_interaction_view + ) + assertThat(mathExpressionInteractionView.text.toString()).isEmpty() + } + } + } + + @Test // TODO(#4692): Robolectric tests not working on screen rotation for input interactions + @RunOn(TestPlatform.ESPRESSO) + fun testStateFragment_checkPreviousHeaderCollapsed_retainStateOnConfigChange() { + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { + startPlayingExploration() + clickContinueInteractionButton() + + // Attempt to submit an wrong answer. + typeFractionText("1/4") + clickSubmitAnswerButton() + + // Attempt to submit a second wrong answer. + typeFractionText("1/4") + clickSubmitAnswerButton() + + // Rotate screen (by default responses are collapsed). + rotateToLandscape() + + // Only the latest failed answer should be showing. + onView(withId(R.id.state_recycler_view)) + .check( + matchesChildren(matcher = withId(R.id.submitted_answer_container), times = 1) + ) + } + } + @Test fun testStateFragment_ratioInput_textViewHasTextInputType() { launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = false).use { scenario -> @@ -4277,6 +4763,36 @@ class StateFragmentTest { return find { text in it.first }?.second } + private fun matchesChildren(matcher: Matcher, times: Int): ViewAssertion { + return matches( + object : TypeSafeMatcher() { + override fun describeTo(description: Description?) { + description + ?.appendDescriptionOf(matcher) + ?.appendText(" occurs times: $times in child views") + } + + override fun matchesSafely(view: View?): Boolean { + if (view !is ViewGroup) { + throw PerformException.Builder() + .withCause(IllegalStateException("Expected to match against view group, not: $view")) + .build() + } + val matchingCount = view.children.filter(matcher::matches).count() + if (matchingCount != times) { + throw PerformException.Builder() + .withActionDescription("Expected to match $matcher against $times children") + .withViewDescription("$view") + .withCause( + IllegalStateException("Matched $matchingCount times in $view (expected $times)") + ) + .build() + } + return true + } + }) + } + @Module class TestModule { @Provides @@ -4300,7 +4816,8 @@ class StateFragmentTest { @Singleton @Component( modules = [ - TestModule::class, RobolectricModule::class, PlatformParameterModule::class, + TestPlatformParameterModule::class, + TestModule::class, RobolectricModule::class, TestDispatcherModule::class, ApplicationModule::class, LoggerModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt index ef388cecfe0..d79823c64e0 100644 --- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt +++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt @@ -8,6 +8,7 @@ import org.oppia.android.util.platformparameter.CacheLatexRendering import org.oppia.android.util.platformparameter.ENABLE_DOWNLOADS_SUPPORT_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EDIT_ACCOUNTS_OPTIONS_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EXTRA_TOPIC_TABS_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.ENABLE_HINT_BULB_ANIMATION import org.oppia.android.util.platformparameter.ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION @@ -15,6 +16,7 @@ import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLE import org.oppia.android.util.platformparameter.EnableDownloadsSupport import org.oppia.android.util.platformparameter.EnableEditAccountsOptionsUi import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi +import org.oppia.android.util.platformparameter.EnableHintBulbAnimation import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi import org.oppia.android.util.platformparameter.EnablePerformanceMetricsCollection @@ -169,4 +171,12 @@ class PlatformParameterAlphaKenyaModule { ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE ) } + + @Provides + @EnableHintBulbAnimation + fun provideEnableHintBulbAnimation(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_HINT_BULB_ANIMATION + ) + } } diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt index 12d50d408ae..0a7f15c25f6 100644 --- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt +++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt @@ -8,6 +8,7 @@ import org.oppia.android.util.platformparameter.CacheLatexRendering import org.oppia.android.util.platformparameter.ENABLE_DOWNLOADS_SUPPORT_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EDIT_ACCOUNTS_OPTIONS_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EXTRA_TOPIC_TABS_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.ENABLE_HINT_BULB_ANIMATION import org.oppia.android.util.platformparameter.ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION @@ -16,6 +17,7 @@ import org.oppia.android.util.platformparameter.ENABLE_SPOTLIGHT_UI_DEFAULT_VALU import org.oppia.android.util.platformparameter.EnableDownloadsSupport import org.oppia.android.util.platformparameter.EnableEditAccountsOptionsUi import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi +import org.oppia.android.util.platformparameter.EnableHintBulbAnimation import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi import org.oppia.android.util.platformparameter.EnablePerformanceMetricsCollection @@ -175,4 +177,12 @@ class PlatformParameterModule { ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE ) } + + @Provides + @EnableHintBulbAnimation + fun provideEnableHintBulbAnimation(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_HINT_BULB_ANIMATION + ) + } } diff --git a/model/src/main/proto/exploration.proto b/model/src/main/proto/exploration.proto index 7f8addd77c4..af183497b15 100644 --- a/model/src/main/proto/exploration.proto +++ b/model/src/main/proto/exploration.proto @@ -322,6 +322,45 @@ message UserAnswer { WrittenTranslationContext written_translation_context = 6; } +// Corresponds to a raw item selection answer that user has selected. +message ItemSelectionRawAnswer { + repeated int32 selected_indexes = 1; +} + +// Corresponds to a raw representation of the current answer entered by the user which is used to +// retain state on configuration changes. Note that this structure is semi-ephemeral in that it will +// only ever be stored on-disk by Android and never by the app. +message RawUserAnswer { + // Represents the last error state the user's pending answer was in (to help with reconstituting + // the answer's state). + AnswerErrorCategory last_error_category = 1; + oneof answer_input_type { + // A raw answer entered by user in a text-based interactions. + string textual_answer = 2; + // A user's selection for item selection and multiple choice interactions. + ItemSelectionRawAnswer item_selection = 3; + // A user's selection for image region selection interaction. + ImageWithRegions.LabeledRegion image_region_selection = 4; + // A user's selected list of set of html content ids in drag and drop interaction. + ListOfSetsOfTranslatableHtmlContentIds list_of_sets_of_translatable_html_content_ids = 5; + } +} + +// Represents categories of errors that can be inferred from a pending answer. +enum AnswerErrorCategory { + // Corresponds to an unknown or unsupported error category. + ANSWER_ERROR_CATEGORY_UNSPECIFIED = 0; + + // Corresponds to the pending answer having zero errors. + NO_ERROR = 1; + + // Corresponds to errors that may be found while the user is trying to input an answer. + REAL_TIME = 2; + + // Corresponds to errors that may be found only when a user tries to submit an answer. + SUBMIT_TIME = 3; +} + message AnswerAndResponse { // A previous answer the learner submitted. UserAnswer user_answer = 1; diff --git a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt index 7586697586d..0485662af48 100644 --- a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt +++ b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt @@ -9,12 +9,14 @@ import org.oppia.android.util.platformparameter.CacheLatexRendering import org.oppia.android.util.platformparameter.ENABLE_DOWNLOADS_SUPPORT_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EDIT_ACCOUNTS_OPTIONS_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EXTRA_TOPIC_TABS_UI_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.ENABLE_HINT_BULB_ANIMATION import org.oppia.android.util.platformparameter.ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION_DEFAULT_VALUE import org.oppia.android.util.platformparameter.EnableDownloadsSupport import org.oppia.android.util.platformparameter.EnableEditAccountsOptionsUi import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi +import org.oppia.android.util.platformparameter.EnableHintBulbAnimation import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi import org.oppia.android.util.platformparameter.EnablePerformanceMetricsCollection @@ -174,6 +176,14 @@ class TestPlatformParameterModule { fun provideEnableInteractionConfigChangeStateRetention(): PlatformParameterValue = PlatformParameterValue.createDefaultParameter(enableInteractionConfigChangeStateRetention) + @Provides + @EnableHintBulbAnimation + fun provideEnableHintBulbAnimation(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + enableHintBulbAnimation + ) + } + companion object { private var enableDownloadsSupport = ENABLE_DOWNLOADS_SUPPORT_DEFAULT_VALUE private var enableLanguageSelectionUi = ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE @@ -182,6 +192,7 @@ class TestPlatformParameterModule { private var enableExtraTopicTabsUi = ENABLE_EXTRA_TOPIC_TABS_UI_DEFAULT_VALUE private var enableInteractionConfigChangeStateRetention = ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE + private var enableHintBulbAnimation = ENABLE_HINT_BULB_ANIMATION private var enablePerformanceMetricsCollection = ENABLE_PERFORMANCE_METRICS_COLLECTION_DEFAULT_VALUE @@ -221,6 +232,12 @@ class TestPlatformParameterModule { enableInteractionConfigChangeStateRetention = value } + /** Enables forcing [EnableHintBulbAnimation] platform parameter flag from tests. */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun forceEnableHintBulbAnimation(value: Boolean) { + enableInteractionConfigChangeStateRetention = value + } + /** Enables forcing [EnablePerformanceMetricsCollection] platform parameter flag from tests. */ @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun forceEnablePerformanceMetricsCollection(value: Boolean) { @@ -237,6 +254,7 @@ class TestPlatformParameterModule { enableInteractionConfigChangeStateRetention = ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE enablePerformanceMetricsCollection = ENABLE_PERFORMANCE_METRICS_COLLECTION_DEFAULT_VALUE + enableHintBulbAnimation = ENABLE_HINT_BULB_ANIMATION } } } diff --git a/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt b/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt index b753afa8449..5544fb9f9e3 100644 --- a/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt +++ b/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt @@ -210,8 +210,8 @@ annotation class EnableSpotlightUi const val ENABLE_SPOTLIGHT_UI_DEFAULT_VALUE = true /** - * Qualifier for the platform parameter that controls whether input interaction state is correctly - * retained across configuration changes. + * Qualifier for the platform parameter that controls whether input interaction state is + * correctly retained across configuration changes. */ @Qualifier annotation class EnableInteractionConfigChangeStateRetention @@ -219,4 +219,13 @@ annotation class EnableInteractionConfigChangeStateRetention /** * Default value for feature flag corresponding to [EnableInteractionConfigChangeStateRetention]. */ -const val ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE = false +const val ENABLE_INTERACTION_CONFIG_CHANGE_STATE_RETENTION_DEFAULT_VALUE = true + +/** + * Qualifier for the platform parameter that controls the animation for hint bulb animation + */ +@Qualifier +annotation class EnableHintBulbAnimation + +/** Default value for feature flag corresponding to [EnableHintBulbAnimation]. */ +const val ENABLE_HINT_BULB_ANIMATION = true