diff --git a/app/build.gradle b/app/build.gradle index 01e18f2512b..5ea497d7b75 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,8 +105,8 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', - 'com.google.truth:truth:0.43', 'com.github.bumptech.glide:mocks:4.11.0', + 'com.google.truth:truth:0.43', 'org.robolectric:annotations:4.3', 'org.robolectric:robolectric:4.3', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', @@ -121,6 +121,7 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', + 'com.github.bumptech.glide:mocks:4.11.0', 'com.google.truth:truth:0.43', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', 'org.mockito:mockito-android:2.7.22', diff --git a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt index f2f2a28ea89..6173c2863bf 100644 --- a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt +++ b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt @@ -5,6 +5,7 @@ import android.app.Application import dagger.BindsInstance import dagger.Component import org.oppia.app.activity.ActivityComponent +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.IntentFactoryShimModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -57,7 +58,8 @@ import javax.inject.Singleton LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, RatioInputModule::class, - UncaughtExceptionLoggerModule::class, ApplicationStartupListenerModule::class + UncaughtExceptionLoggerModule::class, ApplicationStartupListenerModule::class, + HintsAndSolutionConfigModule::class ] ) diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt index 16e9af95e57..3722a413325 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivity.kt @@ -18,6 +18,7 @@ import org.oppia.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.app.player.state.listener.StateKeyboardButtonListener import org.oppia.app.player.stopplaying.StopExplorationDialogFragment import org.oppia.app.player.stopplaying.StopStatePlayingSessionListener +import org.oppia.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject private const val TAG_STOP_EXPLORATION_DIALOG = "STOP_EXPLORATION_DIALOG" @@ -34,7 +35,8 @@ class ExplorationActivity : RevealHintListener, RevealSolutionInterface, DefaultFontSizeStateListener, - HintsAndSolutionExplorationManagerListener { + HintsAndSolutionExplorationManagerListener, + ConceptCardListener { @Inject lateinit var explorationActivityPresenter: ExplorationActivityPresenter @@ -181,4 +183,6 @@ class ExplorationActivity : override fun onExplorationStateLoaded(state: State) { this.state = state } + + override fun dismissConceptCard() = explorationActivityPresenter.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt index ed6d0b1f19d..04e86cf55ca 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt @@ -224,6 +224,10 @@ class ExplorationActivityPresenter @Inject constructor( } } + fun dismissConceptCard() { + getExplorationFragment()?.dismissConceptCard() + } + private fun updateToolbarTitle(explorationId: String) { subscribeToExploration(explorationDataController.getExplorationById(explorationId)) } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt index 0d63c28c2b4..592bb449105 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt @@ -107,4 +107,6 @@ class ExplorationFragment : InjectableFragment() { fun revealSolution(saveUserChoice: Boolean) { explorationFragmentPresenter.revealSolution(saveUserChoice) } + + fun dismissConceptCard() = explorationFragmentPresenter.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt index fd7e9173042..371dd206bae 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt @@ -62,6 +62,8 @@ class ExplorationFragmentPresenter @Inject constructor( getStateFragment()?.revealSolution(saveUserChoice) } + fun dismissConceptCard() = getStateFragment()?.dismissConceptCard() + private fun getStateFragment(): StateFragment? { return fragment .childFragmentManager diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt index 5bfbf9a9c80..78d096f70d1 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt @@ -129,4 +129,6 @@ class StateFragment : fun revealSolution(saveUserChoice: Boolean) { stateFragmentPresenter.revealSolution(saveUserChoice) } + + fun dismissConceptCard() = stateFragmentPresenter.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt index b938f5aba77..28e5f119432 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt @@ -235,7 +235,9 @@ class StateFragmentPresenter @Inject constructor( .addAudioVoiceoverSupport( explorationId, viewModel.currentStateName, viewModel.isAudioBarVisible, this::getAudioUiManager - ).build() + ) + .addConceptCardSupport() + .build() } fun revealHint(saveUserChoice: Boolean, hintIndex: Int) { @@ -461,6 +463,14 @@ class StateFragmentPresenter @Inject constructor( subscribeToAnswerOutcome(explorationProgressController.submitAnswer(answer)) } + fun dismissConceptCard() { + fragment.childFragmentManager.findFragmentByTag( + CONCEPT_CARD_DIALOG_FRAGMENT_TAG + )?.let { dialogFragment -> + fragment.childFragmentManager.beginTransaction().remove(dialogFragment).commitNow() + } + } + private fun moveToNextState() { viewModel.setCanSubmitAnswer(canSubmitAnswer = false) explorationProgressController.moveToNextState().observe( @@ -487,8 +497,6 @@ class StateFragmentPresenter @Inject constructor( binding.stateRecyclerView.smoothScrollToPosition(0) } - private fun isAudioShowing(): Boolean = viewModel.isAudioBarVisible.get()!! - /** Updates submit button UI as active if pendingAnswerError null else inactive. */ fun updateSubmitButton(pendingAnswerError: String?, inputAnswerAvailable: Boolean) { if (inputAnswerAvailable) { diff --git a/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt index fed7f20633a..83b42313348 100644 --- a/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -50,6 +50,9 @@ import org.oppia.app.player.state.StatePlayerRecyclerViewAssembler.Builder.Facto import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver +import org.oppia.app.player.state.hintsandsolution.DelayShowAdditionalHintsFromWrongAnswerMillis +import org.oppia.app.player.state.hintsandsolution.DelayShowAdditionalHintsMillis +import org.oppia.app.player.state.hintsandsolution.DelayShowInitialHintMillis import org.oppia.app.player.state.itemviewmodel.ContentViewModel import org.oppia.app.player.state.itemviewmodel.ContinueInteractionViewModel import org.oppia.app.player.state.itemviewmodel.ContinueNavigationButtonViewModel @@ -79,16 +82,17 @@ import org.oppia.app.player.state.listener.ReturnToTopicNavigationButtonListener import org.oppia.app.player.state.listener.ShowHintAvailabilityListener import org.oppia.app.player.state.listener.SubmitNavigationButtonListener import org.oppia.app.recyclerview.BindableAdapter +import org.oppia.app.topic.conceptcard.ConceptCardFragment import org.oppia.app.utility.LifecycleSafeTimerFactory import org.oppia.util.parser.HtmlParser import org.oppia.util.threading.BackgroundDispatcher import javax.inject.Inject -private const val DELAY_SHOW_INITIAL_HINT_MS = 60_000L -private const val DELAY_SHOW_ADDITIONAL_HINTS_MS = 30_000L -private const val DELAY_SHOW_ADDITIONAL_HINTS_FROM_WRONG_ANSWER_MS = 10_000L private typealias AudioUiManagerRetriever = () -> AudioUiManager? +/** The fragment tag corresponding to the concept card dialog fragment. */ +const val CONCEPT_CARD_DIALOG_FRAGMENT_TAG = "CONCEPT_CARD_FRAGMENT" + /** * An assembler for generating the list of view models to bind to the state player recycler view. * This class also handles some non-recycler view feature management, such as the congratulations @@ -125,8 +129,11 @@ class StatePlayerRecyclerViewAssembler private constructor( private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, backgroundCoroutineDispatcher: CoroutineDispatcher, - private val hasConversationView: Boolean -) { + private val hasConversationView: Boolean, + delayShowInitialHintMs: Long, + delayShowAdditionalHintsMs: Long, + delayShowAdditionalHintsFromWrongAnswerMs: Long +) : HtmlParser.CustomOppiaTagActionListener { /** * A list of view models corresponding to past view models that are hidden by default. These are * intentionally not retained upon configuration changes since the user can just re-expand the @@ -145,7 +152,13 @@ class StatePlayerRecyclerViewAssembler private constructor( private val lifecycleSafeTimerFactory = LifecycleSafeTimerFactory(backgroundCoroutineDispatcher) - private val hintHandler = HintHandler(lifecycleSafeTimerFactory, fragment) + private val hintHandler = HintHandler( + lifecycleSafeTimerFactory, + fragment, + delayShowInitialHintMs, + delayShowAdditionalHintsMs, + delayShowAdditionalHintsFromWrongAnswerMs + ) /** The most recent content ID read by the audio system. */ private var audioPlaybackContentId: String? = null @@ -166,6 +179,12 @@ class StatePlayerRecyclerViewAssembler private constructor( private val isSplitView = ObservableField(false) + override fun onConceptCardLinkClicked(view: View, skillId: String) { + ConceptCardFragment + .newInstance(skillId) + .showNow(fragment.childFragmentManager, CONCEPT_CARD_DIALOG_FRAGMENT_TAG) + } + /** * Computes a list of view models corresponding to the specified [EphemeralState] and the * configuration of this assembler, as well as the GCS entity ID that should be associated with @@ -287,7 +306,8 @@ class StatePlayerRecyclerViewAssembler private constructor( contentSubtitledHtml.html, gcsEntityId, hasConversationView, - isSplitView.get()!! + isSplitView.get()!!, + playerFeatureSet.conceptCardSupport ) } @@ -489,7 +509,13 @@ class StatePlayerRecyclerViewAssembler private constructor( isAnswerCorrect: Boolean ): SubmittedAnswerViewModel { val submittedAnswerViewModel = - SubmittedAnswerViewModel(userAnswer, gcsEntityId, hasConversationView, isSplitView.get()!!) + SubmittedAnswerViewModel( + userAnswer, + gcsEntityId, + hasConversationView, + isSplitView.get()!!, + playerFeatureSet.conceptCardSupport + ) submittedAnswerViewModel.isCorrectAnswer.set(isAnswerCorrect) submittedAnswerViewModel.isExtraInteractionAnswerCorrect.set(isAnswerCorrect) return submittedAnswerViewModel @@ -501,7 +527,13 @@ class StatePlayerRecyclerViewAssembler private constructor( ): FeedbackViewModel? { // Only show feedback if there's some to show. if (feedback.html.isNotEmpty()) { - return FeedbackViewModel(feedback.html, gcsEntityId, hasConversationView, isSplitView.get()!!) + return FeedbackViewModel( + feedback.html, + gcsEntityId, + hasConversationView, + isSplitView.get()!!, + playerFeatureSet.conceptCardSupport + ) } return null } @@ -715,7 +747,10 @@ class StatePlayerRecyclerViewAssembler private constructor( private val entityType: String, private val fragment: Fragment, private val interactionViewModelFactoryMap: Map, - private val backgroundCoroutineDispatcher: CoroutineDispatcher + private val backgroundCoroutineDispatcher: CoroutineDispatcher, + private val delayShowInitialHintMs: Long, + private val delayShowAdditionalHintsMs: Long, + private val delayShowAdditionalHintsFromWrongAnswerMs: Long ) { private val adapterBuilder = BindableAdapter.MultiTypeBuilder.newBuilder( StateItemViewModel::viewType @@ -732,6 +767,13 @@ class StatePlayerRecyclerViewAssembler private constructor( private var currentStateName: ObservableField? = null private var isAudioPlaybackEnabled: ObservableField? = null private var audioUiManagerRetriever: AudioUiManagerRetriever? = null + private val customTagListener = object : HtmlParser.CustomOppiaTagActionListener { + var proxyListener: HtmlParser.CustomOppiaTagActionListener? = null + + override fun onConceptCardLinkClicked(view: View, skillId: String) { + proxyListener?.onConceptCardLinkClicked(view, skillId) + } + } /** Adds support for displaying state content to the learner. */ fun addContentSupport(): Builder { @@ -753,9 +795,13 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, contentViewModel.gcsEntityId, - imageCenterAlign = true + imageCenterAlign = true, + customOppiaTagActionListener = customTagListener ).parseOppiaHtml( - contentViewModel.htmlContent.toString(), binding.contentTextView + contentViewModel.htmlContent.toString(), + binding.contentTextView, + supportsLinks = true, + supportsConceptCards = contentViewModel.supportsConceptCards ) } ) @@ -783,10 +829,13 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, feedbackViewModel.gcsEntityId, - imageCenterAlign = true + imageCenterAlign = true, + customOppiaTagActionListener = customTagListener ).parseOppiaHtml( feedbackViewModel.htmlContent.toString(), - binding.feedbackTextView + binding.feedbackTextView, + supportsLinks = true, + supportsConceptCards = feedbackViewModel.supportsConceptCards ) } ) @@ -870,18 +919,23 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, submittedAnswerViewModel.gcsEntityId, - imageCenterAlign = false + imageCenterAlign = false, + customOppiaTagActionListener = customTagListener ) binding.submittedAnswer = htmlParser.parseOppiaHtml( userAnswer.htmlAnswer, - binding.submittedAnswerTextView + binding.submittedAnswerTextView, + supportsConceptCards = submittedAnswerViewModel.supportsConceptCards ) } UserAnswer.TextualAnswerCase.LIST_OF_HTML_ANSWERS -> { showListOfAnswers(binding) binding.submittedListAnswer = userAnswer.listOfHtmlAnswers binding.submittedAnswerRecyclerView.adapter = - createListAnswerAdapter(submittedAnswerViewModel.gcsEntityId) + createListAnswerAdapter( + submittedAnswerViewModel.gcsEntityId, + submittedAnswerViewModel.supportsConceptCards + ) } else -> { showSingleAnswer(binding) @@ -895,7 +949,10 @@ class StatePlayerRecyclerViewAssembler private constructor( return this } - private fun createListAnswerAdapter(gcsEntityId: String): BindableAdapter { + private fun createListAnswerAdapter( + gcsEntityId: String, + supportsConceptCards: Boolean + ): BindableAdapter { return BindableAdapter.SingleTypeBuilder .newBuilder() .registerViewBinder( @@ -907,13 +964,17 @@ class StatePlayerRecyclerViewAssembler private constructor( bindView = { view, viewModel -> val binding = DataBindingUtil.findBinding(view)!! binding.answerItem = viewModel - binding.submittedHtmlAnswerRecyclerView.adapter = createNestedAdapter(gcsEntityId) + binding.submittedHtmlAnswerRecyclerView.adapter = + createNestedAdapter(gcsEntityId, supportsConceptCards) } ) .build() } - private fun createNestedAdapter(gcsEntityId: String): BindableAdapter { + private fun createNestedAdapter( + gcsEntityId: String, + supportsConceptCards: Boolean + ): BindableAdapter { return BindableAdapter.SingleTypeBuilder .newBuilder() .registerViewBinder( @@ -929,9 +990,12 @@ class StatePlayerRecyclerViewAssembler private constructor( resourceBucketName, entityType, gcsEntityId, - /* imageCenterAlign= */ false + imageCenterAlign = false, + customOppiaTagActionListener = customTagListener ).parseOppiaHtml( - viewModel, binding.submittedAnswerContentTextView + viewModel, + binding.submittedAnswerContentTextView, + supportsConceptCards = supportsConceptCards ) } ) @@ -1091,13 +1155,19 @@ class StatePlayerRecyclerViewAssembler private constructor( return this } + /** Adds support for enabling concept cards links in explorations when the user gets stuck. */ + fun addConceptCardSupport(): Builder { + featureSets += PlayerFeatureSet(conceptCardSupport = true) + return this + } + /** * Returns a new [StatePlayerRecyclerViewAssembler] based on the builder-specified * configuration. */ fun build(): StatePlayerRecyclerViewAssembler { val playerFeatureSet = featureSets.reduce(PlayerFeatureSet::union) - return StatePlayerRecyclerViewAssembler( + val assembler = StatePlayerRecyclerViewAssembler( /* adapter= */ adapterBuilder.build(), /* rhsAdapter= */ adapterBuilder.build(), playerFeatureSet, @@ -1110,8 +1180,15 @@ class StatePlayerRecyclerViewAssembler private constructor( audioUiManagerRetriever, interactionViewModelFactoryMap, backgroundCoroutineDispatcher, - hasConversationView + hasConversationView, + delayShowInitialHintMs, + delayShowAdditionalHintsMs, + delayShowAdditionalHintsFromWrongAnswerMs ) + if (playerFeatureSet.conceptCardSupport) { + customTagListener.proxyListener = assembler + } + return assembler } /** Fragment injectable factory to create new [Builder]s. */ @@ -1120,7 +1197,10 @@ class StatePlayerRecyclerViewAssembler private constructor( private val fragment: Fragment, private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, - @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher + @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher, + @DelayShowInitialHintMillis private val delayShowInitialHintMs: Long, + @DelayShowAdditionalHintsMillis private val delayShowAdditionalHintsMs: Long, + @DelayShowAdditionalHintsFromWrongAnswerMillis private val additionalAnswerHintDelayMs: Long ) { /** * Returns a new [Builder] for the specified GCS resource bucket information for loading @@ -1133,7 +1213,10 @@ class StatePlayerRecyclerViewAssembler private constructor( entityType, fragment, interactionViewModelFactoryMap, - backgroundCoroutineDispatcher + backgroundCoroutineDispatcher, + delayShowInitialHintMs, + delayShowAdditionalHintsMs, + additionalAnswerHintDelayMs ) } } @@ -1152,7 +1235,8 @@ class StatePlayerRecyclerViewAssembler private constructor( val returnToTopicNavigation: Boolean = false, val showCongratulationsOnCorrectAnswer: Boolean = false, val hintsAndSolutionsSupport: Boolean = false, - val supportAudioVoiceovers: Boolean = false + val supportAudioVoiceovers: Boolean = false, + val conceptCardSupport: Boolean = false ) { /** * Returns a union of this feature set with other one. Loosely based on @@ -1172,7 +1256,8 @@ class StatePlayerRecyclerViewAssembler private constructor( showCongratulationsOnCorrectAnswer = showCongratulationsOnCorrectAnswer || other.showCongratulationsOnCorrectAnswer, hintsAndSolutionsSupport = hintsAndSolutionsSupport || other.hintsAndSolutionsSupport, - supportAudioVoiceovers = supportAudioVoiceovers || other.supportAudioVoiceovers + supportAudioVoiceovers = supportAudioVoiceovers || other.supportAudioVoiceovers, + conceptCardSupport = conceptCardSupport || other.conceptCardSupport ) } } @@ -1216,7 +1301,10 @@ class StatePlayerRecyclerViewAssembler private constructor( */ private class HintHandler( private val lifecycleSafeTimerFactory: LifecycleSafeTimerFactory, - private val fragment: Fragment + private val fragment: Fragment, + private val delayShowInitialHintMs: Long, + private val delayShowAdditionalHintsMs: Long, + private val delayShowAdditionalHintsFromWrongAnswerMs: Long ) { private var trackedWrongAnswerCount = 0 private var previousHelpIndex: HelpIndex = HelpIndex.getDefaultInstance() @@ -1251,11 +1339,9 @@ class StatePlayerRecyclerViewAssembler private constructor( return } - // If hint was visibile in the current state show all previous hints - // coming back to current state. - // If any hint was revealed and user move between current and completed states then - // show those relevead hints back by making icon visible - // else use the previous help index + // If hint was visible in the current state show all previous hints coming back to the current + // state. If any hint was revealed and user move between current and completed states, then + // show those revealed hints back by making icon visible else use the previous help index. if (isHintVisibleInLatestState) { if (state.interaction.hintList[previousHelpIndex.hintIndex].hintIsRevealed) { (fragment as ShowHintAvailabilityListener).onHintAvailable( @@ -1281,9 +1367,9 @@ class StatePlayerRecyclerViewAssembler private constructor( if (isFirstHint) { // The learner needs to wait longer for the initial hint to show since they need some time // to read through and consider the question. - scheduleShowHint(DELAY_SHOW_INITIAL_HINT_MS, nextUnrevealedHintIndex) + scheduleShowHint(delayShowInitialHintMs, nextUnrevealedHintIndex) } else { - scheduleShowHint(DELAY_SHOW_ADDITIONAL_HINTS_MS, nextUnrevealedHintIndex) + scheduleShowHint(delayShowAdditionalHintsMs, nextUnrevealedHintIndex) } } else { // See if the learner's new wrong answer justifies showing a hint. @@ -1296,7 +1382,7 @@ class StatePlayerRecyclerViewAssembler private constructor( } else { // Otherwise, always schedule to show a hint on a new wrong answer for subsequent hints. scheduleShowHint( - DELAY_SHOW_ADDITIONAL_HINTS_FROM_WRONG_ANSWER_MS, + delayShowAdditionalHintsFromWrongAnswerMs, nextUnrevealedHintIndex ) } diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt new file mode 100644 index 00000000000..b2a13d421ce --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsFromWrongAnswerMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.app.player.state.hintsandsolution + +import javax.inject.Qualifier + +/** + * Qualifier for a [Long] representing how many milliseconds to wait before showing hints after the + * user submits one wrong answer (subsequent wrong answers will immediately show the next hint). + */ +@Qualifier +annotation class DelayShowAdditionalHintsFromWrongAnswerMillis diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt new file mode 100644 index 00000000000..fe90b11af3c --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowAdditionalHintsMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.app.player.state.hintsandsolution + +import javax.inject.Qualifier + +/** + * Qualifier for a [Long] representing how many milliseconds to wait before showing subsequent hints + * if the user has no activity other than seeing the previous hint. + */ +@Qualifier +annotation class DelayShowAdditionalHintsMillis diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt new file mode 100644 index 00000000000..6ed6293aaaf --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/DelayShowInitialHintMillis.kt @@ -0,0 +1,10 @@ +package org.oppia.app.player.state.hintsandsolution + +import javax.inject.Qualifier + +/** + * Qualifier for a [Long] representing how many milliseconds to initially wait before showing hints + * to potentially stuck users. + */ +@Qualifier +annotation class DelayShowInitialHintMillis diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt new file mode 100644 index 00000000000..ec36968018d --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigFastShowTestModule.kt @@ -0,0 +1,20 @@ +package org.oppia.app.player.state.hintsandsolution + +import dagger.Module +import dagger.Provides + +/** Test-only module for providing configurations to quickly reveal hints & solutions */ +@Module +class HintsAndSolutionConfigFastShowTestModule { + @Provides + @DelayShowInitialHintMillis + fun provideInitialDelayForShowingHintsMillis(): Long = 1L + + @Provides + @DelayShowAdditionalHintsMillis + fun provideDelayForShowingAdditionalHintsMillis(): Long = 1L + + @Provides + @DelayShowAdditionalHintsFromWrongAnswerMillis + fun provideDelayForShowingHintsAfterOneWrongAnswerMillis(): Long = 1L +} diff --git a/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt new file mode 100644 index 00000000000..bd6499f24b0 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/hintsandsolution/HintsAndSolutionConfigModule.kt @@ -0,0 +1,21 @@ +package org.oppia.app.player.state.hintsandsolution + +import dagger.Module +import dagger.Provides +import java.util.concurrent.TimeUnit + +/** Production module for providing configurations for hints & solutions */ +@Module +class HintsAndSolutionConfigModule { + @Provides + @DelayShowInitialHintMillis + fun provideInitialDelayForShowingHintsMillis(): Long = TimeUnit.SECONDS.toMillis(60) + + @Provides + @DelayShowAdditionalHintsMillis + fun provideDelayForShowingAdditionalHintsMillis(): Long = TimeUnit.SECONDS.toMillis(30) + + @Provides + @DelayShowAdditionalHintsFromWrongAnswerMillis + fun provideDelayForShowingHintsAfterOneWrongAnswerMillis(): Long = TimeUnit.SECONDS.toMillis(10) +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt index 3e3890e30c7..3d2c53cd2d7 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt @@ -5,5 +5,6 @@ class ContentViewModel( val htmlContent: CharSequence, val gcsEntityId: String, val hasConversationView: Boolean, - val isSplitView: Boolean + val isSplitView: Boolean, + val supportsConceptCards: Boolean ) : StateItemViewModel(ViewType.CONTENT) diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt index 4ff666ec411..105c0114b54 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt @@ -5,5 +5,6 @@ class FeedbackViewModel( val htmlContent: CharSequence, val gcsEntityId: String, val hasConversationView: Boolean, - val isSplitView: Boolean + val isSplitView: Boolean, + val supportsConceptCards: Boolean ) : StateItemViewModel(ViewType.FEEDBACK) diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt index b1b3d02b52c..8ed55fb0da4 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt @@ -8,7 +8,8 @@ class SubmittedAnswerViewModel( val submittedUserAnswer: UserAnswer, val gcsEntityId: String, val hasConversationView: Boolean, - val isSplitView: Boolean + val isSplitView: Boolean, + val supportsConceptCards: Boolean ) : StateItemViewModel(ViewType.SUBMITTED_ANSWER) { val isCorrectAnswer = ObservableField(false) val isExtraInteractionAnswerCorrect = ObservableField(false) diff --git a/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt b/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt index 0a0b0e026cb..1022569783a 100644 --- a/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/ConceptCardFragmentTestActivity.kt @@ -18,7 +18,7 @@ class ConceptCardFragmentTestActivity : InjectableAppCompatActivity(), ConceptCa conceptCardFragmentTestActivityController.handleOnCreate() } - override fun dismiss() { + override fun dismissConceptCard() { getConceptCardFragment()?.dismiss() } diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt index 1078ae4763a..85fd2db10fa 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragmentPresenter.kt @@ -45,7 +45,7 @@ class ConceptCardFragmentPresenter @Inject constructor( R.string.concept_card_close_icon_description ) binding.conceptCardToolbar.setNavigationOnClickListener { - (fragment.requireActivity() as? ConceptCardListener)?.dismiss() + (fragment.requireActivity() as? ConceptCardListener)?.dismissConceptCard() } binding.let { diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt index 7901f4006e7..d1aa2d64f89 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardListener.kt @@ -3,5 +3,5 @@ package org.oppia.app.topic.conceptcard /** Allows parent activity to dismiss the [ConceptCardFragment] */ interface ConceptCardListener { /** Called when the concept card dialog should be dismissed. */ - fun dismiss() + fun dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt index 1a1420737de..2d5a67118f2 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivity.kt @@ -15,6 +15,7 @@ import org.oppia.app.player.state.listener.StateKeyboardButtonListener import org.oppia.app.player.stopplaying.RestartPlayingSessionListener import org.oppia.app.player.stopplaying.StopExplorationDialogFragment import org.oppia.app.player.stopplaying.StopStatePlayingSessionListener +import org.oppia.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject const val QUESTION_PLAYER_ACTIVITY_SKILL_ID_LIST_ARGUMENT_KEY = @@ -31,7 +32,8 @@ class QuestionPlayerActivity : RouteToHintsAndSolutionListener, RevealHintListener, RevealSolutionInterface, - HintsAndSolutionQuestionManagerListener { + HintsAndSolutionQuestionManagerListener, + ConceptCardListener { @Inject lateinit var questionPlayerActivityPresenter: QuestionPlayerActivityPresenter @@ -119,4 +121,8 @@ class QuestionPlayerActivity : override fun onQuestionStateLoaded(state: State) { this.state = state } + + override fun dismissConceptCard() { + questionPlayerActivityPresenter.dismissConceptCard() + } } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt index 46ca699ba15..8e104d87bd8 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt @@ -170,4 +170,6 @@ class QuestionPlayerActivityPresenter @Inject constructor( ) as QuestionPlayerFragment questionPlayerFragment.revealSolution(saveUserChoice) } + + fun dismissConceptCard() = getQuestionPlayerFragment()?.dismissConceptCard() } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt index 080b1069885..849b0ef9804 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt @@ -82,6 +82,8 @@ class QuestionPlayerFragment : questionPlayerFragmentPresenter.revealSolution(saveUserChoice) } + fun dismissConceptCard() = questionPlayerFragmentPresenter.dismissConceptCard() + override fun onHintAvailable(helpIndex: HelpIndex) = questionPlayerFragmentPresenter.onHintAvailable(helpIndex) } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt index abf9e8b8478..958cfd5c2ff 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt @@ -22,6 +22,7 @@ import org.oppia.app.model.Hint import org.oppia.app.model.Solution import org.oppia.app.model.State import org.oppia.app.model.UserAnswer +import org.oppia.app.player.state.CONCEPT_CARD_DIALOG_FRAGMENT_TAG import org.oppia.app.player.state.StatePlayerRecyclerViewAssembler import org.oppia.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.app.player.stopplaying.RestartPlayingSessionListener @@ -117,6 +118,14 @@ class QuestionPlayerFragmentPresenter @Inject constructor( ) } + fun dismissConceptCard() { + fragment.childFragmentManager.findFragmentByTag( + CONCEPT_CARD_DIALOG_FRAGMENT_TAG + )?.let { dialogFragment -> + fragment.childFragmentManager.beginTransaction().remove(dialogFragment).commitNow() + } + } + fun onHintAvailable(helpIndex: HelpIndex) { when (helpIndex.indexTypeCase) { HelpIndex.IndexTypeCase.HINT_INDEX, HelpIndex.IndexTypeCase.SHOW_SOLUTION -> { @@ -407,6 +416,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( .addReturnToTopicSupport() .addHintsAndSolutionsSupport() .addCongratulationsForCorrectAnswers(congratulationsTextView) + .addConceptCardSupport() .build() } diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt index 7e0c6b5ad06..25f56ed9836 100644 --- a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -3,8 +3,11 @@ package org.oppia.app.player.state import android.app.Application import android.content.Context import android.os.Build +import android.text.Spannable +import android.text.style.ClickableSpan import android.view.View import android.widget.EditText +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario @@ -24,6 +27,7 @@ import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasChildCount import androidx.test.espresso.matcher.ViewMatchers.isClickable @@ -34,9 +38,13 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.espresso.util.HumanReadables import androidx.test.espresso.util.TreeIterables import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.load.engine.executor.MockGlideExecutor import com.google.firebase.FirebaseApp import dagger.BindsInstance import dagger.Component +import kotlinx.coroutines.CoroutineDispatcher import org.hamcrest.BaseMatcher import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString @@ -56,6 +64,7 @@ import org.oppia.app.application.ActivityComponentFactory import org.oppia.app.application.ApplicationComponent import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigFastShowTestModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTENT import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_INTERACTION @@ -95,6 +104,7 @@ import org.oppia.domain.classify.rules.textinput.TextInputRuleModule import org.oppia.domain.onboarding.ExpirationMetaDataRetrieverModule import org.oppia.domain.oppialogger.LogStorageModule import org.oppia.domain.question.QuestionModule +import org.oppia.domain.topic.FRACTIONS_EXPLORATION_ID_1 import org.oppia.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.domain.topic.TEST_EXPLORATION_ID_0 import org.oppia.domain.topic.TEST_EXPLORATION_ID_2 @@ -103,6 +113,8 @@ import org.oppia.domain.topic.TEST_EXPLORATION_ID_5 import org.oppia.domain.topic.TEST_EXPLORATION_ID_6 import org.oppia.domain.topic.TEST_STORY_ID_0 import org.oppia.domain.topic.TEST_TOPIC_ID_0 +import org.oppia.testing.CoroutineExecutorService +import org.oppia.testing.IsOnRobolectric import org.oppia.testing.OppiaTestRule import org.oppia.testing.RunOn import org.oppia.testing.TestAccessibilityModule @@ -117,8 +129,10 @@ import org.oppia.util.logging.LoggerModule import org.oppia.util.parser.GlideImageLoaderModule import org.oppia.util.parser.HtmlParserEntityTypeModule import org.oppia.util.parser.ImageParsingModule +import org.oppia.util.threading.BackgroundDispatcher import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.io.IOException import java.util.concurrent.TimeoutException import javax.inject.Inject import javax.inject.Singleton @@ -140,6 +154,10 @@ class StateFragmentTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher + private val internalProfileId: Int = 1 @Before @@ -149,6 +167,28 @@ class StateFragmentTest { testCoroutineDispatchers.registerIdlingResource() profileTestHelper.initializeProfiles() FirebaseApp.initializeApp(context) + + // Initialize Glide such that all of its executors use the same shared dispatcher pool as the + // rest of Oppia so that thread execution can be synchronized via Oppia's test coroutine + // dispatchers. + val executorService = MockGlideExecutor.newTestExecutor( + CoroutineExecutorService(backgroundCoroutineDispatcher) + ) + Glide.init( + context, + GlideBuilder().setDiskCacheExecutor(executorService) + .setAnimationExecutor(executorService) + .setSourceExecutor(executorService) + ) + + // Only initialize the Robolectric shadows when running on Robolectric (and use reflection since + // Espresso can't load Robolectric into its classpath). + if (isOnRobolectric()) { + val dataSource = createAudioDataSource( + explorationId = FRACTIONS_EXPLORATION_ID_1, audioFileName = "content-en-ouqm7j21vt8.mp3" + ) + addShadowMediaPlayerException(dataSource, IOException("Test does not have networking")) + } } @After @@ -873,6 +913,113 @@ class StateFragmentTest { } } + @Test + fun testStateFragment_forMisconception_showsLinkTextForConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + + // This answer is incorrect and a detected misconception. + typeFractionText("3/2") + clickSubmitAnswerButton() + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("Take a look at the short refresher lesson")) + ) + ) + } + } + + @Test + fun testStateFragment_landscape_forMisconception_showsLinkTextForConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + rotateToLandscape() + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + + // This answer is incorrect and a detected misconception. + typeFractionText("3/2") + clickSubmitAnswerButton() + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("Take a look at the short refresher lesson")) + ) + ) + } + } + + @Test + fun testStateFragment_forMisconception_clickLinkText_opensConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + typeFractionText("3/2") // Misconception. + clickSubmitAnswerButton() + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + @Test + fun testStateFragment_landscape_forMisconception_clickLinkText_opensConceptCard() { + launchForExploration(FRACTIONS_EXPLORATION_ID_1).use { + rotateToLandscape() + startPlayingExploration() + selectMultipleChoiceOption(optionPosition = 3) // No, pieces must be the same size. + clickContinueNavigationButton() + typeFractionText("3/2") // Misconception. + clickSubmitAnswerButton() + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + private fun addShadowMediaPlayerException(dataSource: Any, exception: Exception) { + val classLoader = StateFragmentTest::class.java.classLoader!! + val shadowMediaPlayerClass = classLoader.loadClass("org.robolectric.shadows.ShadowMediaPlayer") + val addException = + shadowMediaPlayerClass.getDeclaredMethod( + "addException", dataSource.javaClass, IOException::class.java + ) + addException.invoke(/* obj= */ null, dataSource, exception) + } + + @Suppress("SameParameterValue") + private fun createAudioDataSource(explorationId: String, audioFileName: String): Any { + val audioUrl = createAudioUrl(explorationId, audioFileName) + val classLoader = StateFragmentTest::class.java.classLoader!! + val dataSourceClass = classLoader.loadClass("org.robolectric.shadows.util.DataSource") + val toDataSource = + dataSourceClass.getDeclaredMethod( + "toDataSource", String::class.java, Map::class.java + ) + return toDataSource.invoke(/* obj= */ null, audioUrl, /* headers= */ null) + } + + private fun createAudioUrl(explorationId: String, audioFileName: String): String { + return "https://storage.googleapis.com/oppiaserver-resources/" + + "exploration/$explorationId/assets/audio/$audioFileName" + } + private fun launchForExploration( explorationId: String ): ActivityScenario { @@ -1118,6 +1265,10 @@ class StateFragmentTest { ApplicationProvider.getApplicationContext().inject(this) } + private fun isOnRobolectric(): Boolean { + return ApplicationProvider.getApplicationContext().isOnRobolectric() + } + // TODO(#59): Remove these waits once we can ensure that the production executors are not depended on in tests. // Sleeping is really bad practice in Espresso tests, and can lead to test flakiness. It shouldn't be necessary if we // use a test executor service with a counting idle resource, but right now Gradle mixes dependencies such that both @@ -1216,6 +1367,56 @@ class StateFragmentTest { } } + /** + * Returns an action that finds a TextView containing the specific text, finds a ClickableSpan + * within that text view that contains the specified text, then clicks it. The need for this was + * inspired by https://stackoverflow.com/q/38314077. + */ + @Suppress("SameParameterValue") + private fun openClickableSpan(text: String): ViewAction { + return object : ViewAction { + override fun getDescription(): String = "openClickableSpan" + + override fun getConstraints(): Matcher = hasClickableSpanWithText(text) + + override fun perform(uiController: UiController?, view: View?) { + // The view shouldn't be null if the constraints are being met. + (view as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text)?.onClick(view) + } + } + } + + /** + * Returns a matcher that matches against text views with clickable spans that contain the + * specified text. + */ + private fun hasClickableSpanWithText(text: String): Matcher { + return object : TypeSafeMatcher(TextView::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("has ClickableSpan with text")?.appendValue(text) + } + + override fun matchesSafely(item: View?): Boolean { + return (item as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text) != null + } + } + } + + private fun TextView.getClickableSpans(): List> { + val viewText = text + return (viewText as Spannable).getSpans( + /* start= */ 0, /* end= */ text.length, ClickableSpan::class.java + ).map { + viewText.subSequence(viewText.getSpanStart(it), viewText.getSpanEnd(it)).toString() to it + } + } + + private fun List>.findMatchingTextOrNull( + text: String + ): ClickableSpan? { + return find { text in it.first }?.second + } + @Singleton @Component( modules = [ @@ -1228,7 +1429,8 @@ class StateFragmentTest { HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigFastShowTestModule::class ] ) interface TestApplicationComponent : ApplicationComponent { @@ -1241,6 +1443,8 @@ class StateFragmentTest { } fun inject(stateFragmentTest: StateFragmentTest) + + @IsOnRobolectric fun isOnRobolectric(): Boolean } class TestApplication : Application(), ActivityComponentFactory { @@ -1250,9 +1454,9 @@ class StateFragmentTest { .build() } - fun inject(stateFragmentTest: StateFragmentTest) { - component.inject(stateFragmentTest) - } + fun inject(stateFragmentTest: StateFragmentTest) = component.inject(stateFragmentTest) + + fun isOnRobolectric(): Boolean = component.isOnRobolectric() override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() diff --git a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt index 9b1355befcc..dd35e25fc0c 100644 --- a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt @@ -35,6 +35,7 @@ import org.oppia.app.application.ApplicationInjectorProvider import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.onboarding.OnboardingActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.profile.ProfileChooserActivity import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -279,7 +280,8 @@ class SplashActivityTest { HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverTestModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt index cabd2976d95..7141ee85e63 100644 --- a/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityTest.kt @@ -2,70 +2,375 @@ package org.oppia.app.topic.questionplayer import android.app.Application import android.content.Context +import android.text.Spannable +import android.text.style.ClickableSpan +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.load.engine.executor.MockGlideExecutor import com.google.firebase.FirebaseApp -import dagger.BindsInstance import dagger.Component import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher +import org.hamcrest.BaseMatcher +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.oppia.app.R +import org.oppia.app.activity.ActivityComponent +import org.oppia.app.application.ActivityComponentFactory +import org.oppia.app.application.ApplicationComponent +import org.oppia.app.application.ApplicationInjector +import org.oppia.app.application.ApplicationInjectorProvider +import org.oppia.app.application.ApplicationModule +import org.oppia.app.application.ApplicationStartupListenerModule +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigFastShowTestModule +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FEEDBACK +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.SELECTION_INTERACTION +import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView +import org.oppia.app.shim.ViewBindingShimModule +import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape +import org.oppia.domain.classify.InteractionsModule +import org.oppia.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.domain.classify.rules.ratioinput.RatioInputModule +import org.oppia.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.domain.onboarding.ExpirationMetaDataRetrieverModule +import org.oppia.domain.oppialogger.LogStorageModule +import org.oppia.domain.question.QuestionCountPerTrainingSession +import org.oppia.domain.question.QuestionTrainingSeed +import org.oppia.domain.topic.FRACTIONS_SKILL_ID_0 +import org.oppia.domain.topic.PrimeTopicAssetsControllerModule +import org.oppia.testing.CoroutineExecutorService +import org.oppia.testing.TestAccessibilityModule +import org.oppia.testing.TestCoroutineDispatchers +import org.oppia.testing.TestDispatcherModule +import org.oppia.testing.TestLogReportingModule +import org.oppia.testing.profile.ProfileTestHelper +import org.oppia.util.caching.testing.CachingTestModule +import org.oppia.util.gcsresource.GcsResourceModule +import org.oppia.util.logging.LoggerModule +import org.oppia.util.parser.GlideImageLoaderModule +import org.oppia.util.parser.HtmlParserEntityTypeModule +import org.oppia.util.parser.ImageParsingModule import org.oppia.util.threading.BackgroundDispatcher -import org.oppia.util.threading.BlockingDispatcher +import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import javax.inject.Inject import javax.inject.Singleton +private val SKILL_ID_LIST = listOf(FRACTIONS_SKILL_ID_0) + /** Tests for [QuestionPlayerActivity]. */ @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) +@Config(application = QuestionPlayerActivityTest.TestApplication::class, qualifiers = "port-xxhdpi") class QuestionPlayerActivityTest { // TODO(#503): add tests for QuestionPlayerActivity (use StateFragmentTest for a reference). - // TODO(#1273): add tests for Hints and Solution in Question Player . + // TODO(#1273): add tests for Hints and Solution in Question Player. + + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Inject + lateinit var profileTestHelper: ProfileTestHelper + + @Inject + lateinit var context: Context + + @Inject + @field:BackgroundDispatcher + lateinit var backgroundCoroutineDispatcher: CoroutineDispatcher @Before fun setUp() { - FirebaseApp.initializeApp(ApplicationProvider.getApplicationContext()) + setUpTestApplicationComponent() + testCoroutineDispatchers.registerIdlingResource() + profileTestHelper.initializeProfiles() + FirebaseApp.initializeApp(context) + + // Initialize Glide such that all of its executors use the same shared dispatcher pool as the + // rest of Oppia so that thread execution can be synchronized via Oppia's test coroutine + // dispatchers. + val executorService = MockGlideExecutor.newTestExecutor( + CoroutineExecutorService(backgroundCoroutineDispatcher) + ) + Glide.init( + context, + GlideBuilder().setDiskCacheExecutor(executorService) + .setAnimationExecutor(executorService) + .setSourceExecutor(executorService) + ) + } + + @After + fun tearDown() { + testCoroutineDispatchers.unregisterIdlingResource() + } + + @Test + fun testQuestionPlayer_forMisconception_showsLinkTextForConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + // Option 3 is the wrong answer and should trigger showing a concept card. + selectMultipleChoiceOption(optionPosition = 3) + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("To refresh your memory, take a look at this refresher lesson")) + ) + ) + } } @Test - fun tempTest() { - // TODO(#503): remove. + fun testQuestionPlayer_landscape_forMisconception_showsLinkTextForConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + rotateToLandscape() + + // Option 3 is the wrong answer and should trigger showing a concept card. + selectMultipleChoiceOption(optionPosition = 3) + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).check( + matches( + withText(containsString("To refresh your memory, take a look at this refresher lesson")) + ) + ) + } + } + + @Test + fun testQuestionPlayer_forMisconception_clickLinkText_opensConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + selectMultipleChoiceOption(optionPosition = 3) // Misconception. + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + @Test + fun testQuestionPlayer_landscape_forMisconception_clickLinkText_opensConceptCard() { + launchForSkillList(SKILL_ID_LIST).use { + rotateToLandscape() + selectMultipleChoiceOption(optionPosition = 3) // Misconception. + scrollToViewType(FEEDBACK) + + onView(withId(R.id.feedback_text_view)).perform(openClickableSpan("refresher lesson")) + testCoroutineDispatchers.runCurrent() + + onView(withText("Concept Card")).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withId(R.id.concept_card_heading_text)) + .inRoot(isDialog()) + .check(matches(withText(containsString("Identify the numerator and denominator")))) + } + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private fun launchForSkillList( + skillIdList: List + ): ActivityScenario { + val scenario = ActivityScenario.launch( + QuestionPlayerActivity.createQuestionPlayerActivityIntent( + context, ArrayList(skillIdList) + ) + ) + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.question_recycler_view)).check(matches(isDisplayed())) + return scenario + } + + private fun rotateToLandscape() { + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + } + + // TODO(#1778): Share the following utilities with StateFragmentTest. + + @Suppress("SameParameterValue") + private fun selectMultipleChoiceOption(optionPosition: Int) { + clickSelection(optionPosition, targetViewId = R.id.multiple_choice_radio_button) + } + + @Suppress("SameParameterValue") + private fun clickSelection(optionPosition: Int, targetViewId: Int) { + scrollToViewType(SELECTION_INTERACTION) + onView( + atPositionOnView( + recyclerViewId = R.id.selection_interaction_recyclerview, + position = optionPosition, + targetViewId = targetViewId + ) + ).perform(click()) + testCoroutineDispatchers.runCurrent() + } + + private fun scrollToViewType(viewType: StateItemViewModel.ViewType) { + onView(withId(R.id.question_recycler_view)).perform( + scrollToHolder(StateViewHolderTypeMatcher(viewType)) + ) + testCoroutineDispatchers.runCurrent() + } + + /** + * [BaseMatcher] that matches against the first occurrence of the specified view holder type in + * StateFragment's RecyclerView. + */ + private class StateViewHolderTypeMatcher( + private val viewType: StateItemViewModel.ViewType + ) : BaseMatcher() { + override fun describeTo(description: Description?) { + description?.appendText("item view type of $viewType") + } + + override fun matches(item: Any?): Boolean { + return (item as? RecyclerView.ViewHolder)?.itemViewType == viewType.ordinal + } + } + + /** + * Returns an action that finds a TextView containing the specific text, finds a ClickableSpan + * within that text view that contains the specified text, then clicks it. The need for this was + * inspired by https://stackoverflow.com/q/38314077. + */ + @Suppress("SameParameterValue") + private fun openClickableSpan(text: String): ViewAction { + return object : ViewAction { + override fun getDescription(): String = "openClickableSpan" + + override fun getConstraints(): Matcher = hasClickableSpanWithText(text) + + override fun perform(uiController: UiController?, view: View?) { + // The view shouldn't be null if the constraints are being met. + (view as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text)?.onClick(view) + } + } + } + + /** + * Returns a matcher that matches against text views with clickable spans that contain the + * specified text. + */ + private fun hasClickableSpanWithText(text: String): Matcher { + return object : TypeSafeMatcher(TextView::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("has ClickableSpan with text")?.appendValue(text) + } + + override fun matchesSafely(item: View?): Boolean { + return (item as? TextView)?.getClickableSpans()?.findMatchingTextOrNull(text) != null + } + } + } + + private fun TextView.getClickableSpans(): List> { + val viewText = text + return (viewText as Spannable).getSpans( + /* start= */ 0, /* end= */ text.length, ClickableSpan::class.java + ).map { + viewText.subSequence(viewText.getSpanStart(it), viewText.getSpanEnd(it)).toString() to it + } + } + + private fun List>.findMatchingTextOrNull( + text: String + ): ClickableSpan? { + return find { text in it.first }?.second } @Module class TestModule { @Provides - @Singleton - fun provideContext(application: Application): Context { - return application - } - - // TODO(#89): Introduce a proper IdlingResource for background dispatchers to ensure they all complete before - // proceeding in an Espresso test. This solution should also be interoperative with Robolectric contexts by using a - // test coroutine dispatcher. + @QuestionCountPerTrainingSession + fun provideQuestionCountPerTrainingSession(): Int = 3 - @Singleton + // Ensure that the question seed is consistent for all runs of the tests to keep question order + // predictable. @Provides - @BackgroundDispatcher - fun provideBackgroundDispatcher( - @BlockingDispatcher blockingDispatcher: CoroutineDispatcher - ): CoroutineDispatcher { - return blockingDispatcher - } + @QuestionTrainingSeed + fun provideQuestionTrainingSeed(): Long = 3 } + // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. + // TODO(#1675): Add NetworkModule once data module is migrated off of Moshi. @Singleton - @Component(modules = [TestModule::class]) - interface TestApplicationComponent { + @Component( + modules = [ + TestModule::class, TestDispatcherModule::class, ApplicationModule::class, + LoggerModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, TestLogReportingModule::class, + TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, + PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, + ViewBindingShimModule::class, ApplicationStartupListenerModule::class, + RatioInputModule::class, HintsAndSolutionConfigFastShowTestModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { @Component.Builder - interface Builder { - @BindsInstance - fun setApplication(application: Application): Builder + interface Builder : ApplicationComponent.Builder + + fun inject(questionPlayerActivityTest: QuestionPlayerActivityTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerQuestionPlayerActivityTest_TestApplicationComponent.builder() + .setApplication(this) + .build() as TestApplicationComponent + } + + fun inject(questionPlayerActivityTest: QuestionPlayerActivityTest) { + component.inject(questionPlayerActivityTest) + } - fun build(): TestApplicationComponent + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() } + + override fun getApplicationInjector(): ApplicationInjector = component } } diff --git a/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt index 9c15b98f14b..db734e56dec 100644 --- a/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/home/HomeActivityLocalTest.kt @@ -20,6 +20,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.ACTIVITYCONTEXT_NOT_SET +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.IntentFactoryShimModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -110,7 +111,7 @@ class HomeActivityLocalTest { ImageClickInputModule::class, LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, CachingTestModule::class, RatioInputModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ApplicationStartupListenerModule::class + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt index 3ede967d380..3d4332fc900 100644 --- a/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/player/exploration/ExplorationActivityLocalTest.kt @@ -18,6 +18,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.EXPLORATION_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.IntentFactoryShimModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.testing.ExplorationInjectionActivity @@ -147,7 +148,7 @@ class ExplorationActivityLocalTest { ImageClickInputModule::class, LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, CachingTestModule::class, RatioInputModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ApplicationStartupListenerModule::class + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt index 3118221a916..7434783724d 100644 --- a/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/player/state/StateFragmentLocalTest.kt @@ -53,6 +53,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.hintsandsolution.TAG_REVEAL_SOLUTION_DIALOG import org.oppia.app.player.exploration.TAG_HINTS_AND_SOLUTION_DIALOG +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.CONTINUE_NAVIGATION_BUTTON import org.oppia.app.player.state.itemviewmodel.StateItemViewModel.ViewType.FRACTION_INPUT_INTERACTION @@ -1136,7 +1137,8 @@ class StateFragmentLocalTest { HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt index ac4c115b9fa..8e73c5f09d7 100644 --- a/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/profile/ProfileChooserFragmentLocalTest.kt @@ -23,6 +23,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog.Context.ActivityContextCase.ACTIVITYCONTEXT_NOT_SET import org.oppia.app.model.EventLog.EventAction import org.oppia.app.model.EventLog.Priority +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule import org.oppia.domain.classify.rules.continueinteraction.ContinueModule @@ -110,7 +111,8 @@ class ProfileChooserFragmentLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt b/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt index 0e48aef39aa..7986a7f765d 100644 --- a/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/story/StoryActivityLocalTest.kt @@ -22,6 +22,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.STORY_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule import org.oppia.domain.classify.rules.continueinteraction.ContinueModule @@ -126,7 +127,8 @@ class StoryActivityLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt b/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt index 94c8b481b10..a574039584a 100644 --- a/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt +++ b/app/src/test/java/org/oppia/app/testing/options/AppLanguageFragmentTest.kt @@ -31,6 +31,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.options.APP_LANGUAGE import org.oppia.app.options.AppLanguageActivity import org.oppia.app.options.OptionsActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape @@ -217,7 +218,7 @@ class AppLanguageFragmentTest { ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt b/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt index 2df328aa374..4c95046fcec 100644 --- a/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt +++ b/app/src/test/java/org/oppia/app/testing/options/DefaultAudioFragmentTest.kt @@ -31,6 +31,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.options.AUDIO_LANGUAGE import org.oppia.app.options.DefaultAudioActivity import org.oppia.app.options.OptionsActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape @@ -218,7 +219,7 @@ class DefaultAudioFragmentTest { ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt b/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt index 70eb4d7d33a..715d1b4832c 100644 --- a/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt +++ b/app/src/test/java/org/oppia/app/testing/options/ReadingTextSizeFragmentTest.kt @@ -39,6 +39,7 @@ import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.options.OptionsActivity import org.oppia.app.options.READING_TEXT_SIZE import org.oppia.app.options.ReadingTextSizeActivity +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.utility.OrientationChangeAction.Companion.orientationLandscape @@ -231,7 +232,7 @@ class ReadingTextSizeFragmentTest { ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt b/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt index 4f48324c063..4393e87ec40 100644 --- a/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt +++ b/app/src/test/java/org/oppia/app/testing/player/state/StateFragmentAccessibilityTest.kt @@ -24,6 +24,7 @@ import org.oppia.app.application.ApplicationContext import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.player.state.StateFragment +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.recyclerview.RecyclerViewMatcher import org.oppia.app.shim.IntentFactoryShimModule @@ -169,7 +170,7 @@ class StateFragmentAccessibilityTest { ImageClickInputModule::class, LogStorageModule::class, IntentFactoryShimModule::class, ViewBindingShimModule::class, CachingTestModule::class, RatioInputModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ApplicationStartupListenerModule::class + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt index c7626263554..bb4f2998136 100644 --- a/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/info/TopicInfoFragmentLocalTest.kt @@ -19,6 +19,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.TOPIC_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.topic.TopicActivity import org.oppia.domain.classify.InteractionsModule @@ -114,7 +115,8 @@ class TopicInfoFragmentLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt b/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt index 6ed0263682a..5199c679aa3 100644 --- a/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/lessons/TopicLessonsFragmentLocalTest.kt @@ -18,6 +18,7 @@ import org.oppia.app.application.ApplicationInjectorProvider import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.app.topic.TopicActivity import org.oppia.domain.classify.InteractionsModule @@ -116,7 +117,8 @@ class TopicLessonsFragmentLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt b/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt index 0a86f70a3e1..4d827c6ba96 100644 --- a/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt @@ -36,6 +36,7 @@ import org.oppia.app.application.ApplicationInjector import org.oppia.app.application.ApplicationInjectorProvider import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule @@ -270,7 +271,7 @@ class QuestionPlayerActivityLocalTest { TestAccessibilityModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, ViewBindingShimModule::class, ApplicationStartupListenerModule::class, - RatioInputModule::class + RatioInputModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt b/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt index 1a3138c21ed..ed169c20dac 100644 --- a/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/app/topic/revisioncard/RevisionCardActivityLocalTest.kt @@ -19,6 +19,7 @@ import org.oppia.app.application.ApplicationModule import org.oppia.app.application.ApplicationStartupListenerModule import org.oppia.app.model.EventLog import org.oppia.app.model.EventLog.Context.ActivityContextCase.REVISION_CARD_CONTEXT +import org.oppia.app.player.state.hintsandsolution.HintsAndSolutionConfigModule import org.oppia.app.shim.ViewBindingShimModule import org.oppia.domain.classify.InteractionsModule import org.oppia.domain.classify.rules.continueinteraction.ContinueModule @@ -106,7 +107,8 @@ class RevisionCardActivityLocalTest { QuestionModule::class, TestLogReportingModule::class, TestAccessibilityModule::class, ImageClickInputModule::class, LogStorageModule::class, CachingTestModule::class, PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class, - ViewBindingShimModule::class, RatioInputModule::class, ApplicationStartupListenerModule::class + ViewBindingShimModule::class, RatioInputModule::class, + ApplicationStartupListenerModule::class, HintsAndSolutionConfigModule::class ] ) interface TestApplicationComponent : ApplicationComponent, ApplicationInjector { diff --git a/domain/src/main/assets/MjZzEVOG47_1.json b/domain/src/main/assets/MjZzEVOG47_1.json index e5ca2d5dce7..a3515937f5b 100644 --- a/domain/src/main/assets/MjZzEVOG47_1.json +++ b/domain/src/main/assets/MjZzEVOG47_1.json @@ -1873,7 +1873,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_3", - "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short  to refresh your memory if you need to.

" + "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short to refresh your memory if you need to.

" }, "dest": "Matthew gets conned", "refresher_exploration_id": null, @@ -1900,7 +1900,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_10", - "html": "

That's not correct -- it looks like you've forgotten what the denominator represents. Take a look at the short  to refresh your memory if you need to.

" + "html": "

That's not correct -- it looks like you've forgotten what the numerator and denominator represent. Take a look at the short to refresh your memory if you need to.

" }, "dest": "Matthew gets conned", "refresher_exploration_id": null, diff --git a/domain/src/main/assets/questions.json b/domain/src/main/assets/questions.json index 162411c541a..3b28c92ca51 100644 --- a/domain/src/main/assets/questions.json +++ b/domain/src/main/assets/questions.json @@ -532,7 +532,7 @@ "param_changes": [], "feedback": { "content_id": "feedback_4", - "html": "

No, that's not correct. It looks like you've forgotten what a numerator and denominator are. To refresh your memory, look up the \"How to Identify the Numerator and Denominator of a Fraction\" concept card.

" + "html": "

No, that's not correct. It looks like you've forgotten what a numerator and denominator are. To refresh your memory, take a look at this .

" }, "dest": null, "refresher_exploration_id": null, diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt index 843da343dc9..53bd332423c 100644 --- a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt +++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcherEspressoImpl.kt @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import kotlin.coroutines.CoroutineContext import kotlin.math.min +import kotlinx.coroutines.delay as delayInScope // Needed to avoid conflict with Delay.delay(). /** * Espresso-specific implementation of [TestCoroutineDispatcher]. @@ -78,7 +79,7 @@ class TestCoroutineDispatcherEspressoImpl private constructor( executingTaskCount.incrementAndGet() notifyIfRunning() val delayResult = realCoroutineScope.async { - delay(timeMillis) + delayInScope(timeMillis) } delayResult.invokeOnCompletion { try { diff --git a/utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt b/utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt new file mode 100644 index 00000000000..92757f7e8f0 --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/BulletTagHandler.kt @@ -0,0 +1,37 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.BulletSpan +import org.xml.sax.Attributes + +/** The custom tag corresponding to [BulletTagHandler]. */ +const val CUSTOM_BULLET_LIST_TAG = "oppia-li" + +/** + * A custom tag handler for properly formatting bullet items in HTML parsed with + * [CustomHtmlContentHandler]. + */ +class BulletTagHandler : CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + val spannableBuilder = SpannableStringBuilder( + output.subSequence( + openIndex, + closeIndex + ) + ) + spannableBuilder.append("\n") + if (openIndex != closeIndex) { + spannableBuilder.setSpan( + BulletSpan(), 0, spannableBuilder.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + } + output.replace(openIndex, closeIndex, spannableBuilder) + } +} diff --git a/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt new file mode 100644 index 00000000000..3e617fd4748 --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/CustomHtmlContentHandler.kt @@ -0,0 +1,154 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Html +import android.text.Spannable +import androidx.core.text.HtmlCompat +import org.xml.sax.Attributes +import org.xml.sax.ContentHandler +import org.xml.sax.Locator +import org.xml.sax.XMLReader +import java.util.ArrayDeque + +/** + * A custom [ContentHandler] and [Html.TagHandler] for processing custom HTML tags. This class must + * be used if a custom tag attribute must be parsed. + * + * This is based on the implementation provided in https://stackoverflow.com/a/36528149. + */ +class CustomHtmlContentHandler private constructor( + private val customTagHandlers: Map +) : ContentHandler, Html.TagHandler { + private var originalContentHandler: ContentHandler? = null + private var currentTrackedTag: TrackedTag? = null + private val currentTrackedCustomTags = ArrayDeque() + + override fun endElement(uri: String?, localName: String?, qName: String?) { + originalContentHandler?.endElement(uri, localName, qName) + currentTrackedTag = null + } + + override fun processingInstruction(target: String?, data: String?) { + originalContentHandler?.processingInstruction(target, data) + } + + override fun startPrefixMapping(prefix: String?, uri: String?) { + originalContentHandler?.startPrefixMapping(prefix, uri) + } + + override fun ignorableWhitespace(ch: CharArray?, start: Int, length: Int) { + originalContentHandler?.ignorableWhitespace(ch, start, length) + } + + override fun characters(ch: CharArray?, start: Int, length: Int) { + originalContentHandler?.characters(ch, start, length) + } + + override fun endDocument() { + originalContentHandler?.endDocument() + } + + override fun startElement(uri: String?, localName: String?, qName: String?, atts: Attributes?) { + // Defer custom tag management to the tag handler so that Android's element parsing takes + // precedence. + currentTrackedTag = TrackedTag(checkNotNull(localName), checkNotNull(atts)) + originalContentHandler?.startElement(uri, localName, qName, atts) + } + + override fun skippedEntity(name: String?) { + originalContentHandler?.skippedEntity(name) + } + + override fun setDocumentLocator(locator: Locator?) { + originalContentHandler?.setDocumentLocator(locator) + } + + override fun endPrefixMapping(prefix: String?) { + originalContentHandler?.endPrefixMapping(prefix) + } + + override fun startDocument() { + originalContentHandler?.startDocument() + } + + override fun handleTag(opening: Boolean, tag: String?, output: Editable?, xmlReader: XMLReader?) { + check(output != null) { "Expected non-null editable." } + when { + originalContentHandler == null -> { + check(tag == "init-custom-handler") { + "Expected first custom tag to be initializing the custom handler." + } + checkNotNull(xmlReader) { "Expected reader to not be null" } + originalContentHandler = xmlReader.contentHandler + xmlReader.contentHandler = this + } + opening -> { + if (tag in customTagHandlers) { + val localCurrentTrackedTag = currentTrackedTag + check(localCurrentTrackedTag != null) { + "Expected tag details to be to be cached for current tag." + } + check(localCurrentTrackedTag.tag == tag) { + "Expected tracked tag $currentTrackedTag to match custom tag: $tag" + } + currentTrackedCustomTags += TrackedCustomTag( + localCurrentTrackedTag.tag, localCurrentTrackedTag.attributes, output.length + ) + } + } + tag in customTagHandlers -> { + check(currentTrackedCustomTags.isNotEmpty()) { + "Expected tracked custom tag to be initialized." + } + val currentTrackedCustomTag = currentTrackedCustomTags.removeLast() + check(currentTrackedCustomTag.tag == tag) { + "Expected tracked tag $currentTrackedTag to match custom tag: $tag" + } + val (_, attributes, openTagIndex) = currentTrackedCustomTag + customTagHandlers.getValue(tag).handleTag(attributes, openTagIndex, output.length, output) + } + } + } + + private data class TrackedTag(val tag: String, val attributes: Attributes) + private data class TrackedCustomTag( + val tag: String, + val attributes: Attributes, + val openTagIndex: Int + ) + + /** Handler interface for a custom tag and its attributes. */ + interface CustomTagHandler { + /** + * Called when a custom tag is encountered. This is always called after the closing tag. + * + * @param attributes The tag's attributes + * @param openIndex The index in the output [Editable] at which this tag begins + * @param closeIndex The index in the output [Editable] at which this tag ends + * @param output The destination [Editable] to which spans can be added + */ + fun handleTag(attributes: Attributes, openIndex: Int, closeIndex: Int, output: Editable) + } + + companion object { + /** + * Returns a new [Spannable] with HTML parsed from [html] using the specified [imageGetter] for + * handling image retrieval, and map of tags to [CustomTagHandler]s for handling custom tags. + * All possible custom tags must be registered in the [customTagHandlers] map. + */ + fun fromHtml( + html: String, + imageGetter: Html.ImageGetter, + customTagHandlers: Map + ): Spannable { + // Adjust the HTML to allow the custom content handler to properly initialize custom tag + // tracking. + return HtmlCompat.fromHtml( + "$html", + HtmlCompat.FROM_HTML_MODE_LEGACY, + imageGetter, + CustomHtmlContentHandler(customTagHandlers) + ) as Spannable + } + } +} diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt index 6eb3291de7a..092c06cc203 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -1,11 +1,15 @@ package org.oppia.util.parser +import android.text.Editable import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.method.LinkMovementMethod import android.text.style.BulletSpan +import android.text.style.ClickableSpan +import android.view.View import android.widget.TextView -import androidx.core.text.HtmlCompat +import org.xml.sax.Attributes import javax.inject.Inject private const val CUSTOM_IMG_TAG = "oppia-noninteractive-image" @@ -13,54 +17,66 @@ private const val REPLACE_IMG_TAG = "img" private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value" private const val REPLACE_IMG_FILE_PATH_ATTRIBUTE = "src" +private const val CUSTOM_CONCEPT_CARD_TAG = "oppia-noninteractive-skillreview" + /** Html Parser to parse custom Oppia tags with Android-compatible versions. */ class HtmlParser private constructor( private val urlImageParserFactory: UrlImageParser.Factory, private val gcsResourceName: String, private val entityType: String, private val entityId: String, - private val imageCenterAlign: Boolean + private val imageCenterAlign: Boolean, + customOppiaTagActionListener: CustomOppiaTagActionListener? ) { + private val conceptCardTagHandler = ConceptCardTagHandler(customOppiaTagActionListener) + private val bulletTagHandler = BulletTagHandler() /** - * This method replaces custom Oppia tags with Android-compatible versions for a given raw HTML string, and returns the HTML [Spannable]. - * @param rawString rawString argument is the string from the string-content - * @param htmlContentTextView htmlContentTextView argument is the TextView, that need to be passed as argument to ImageGetter class for image parsing - * @return Spannable Spannable represents the styled text. + * Parses a raw HTML string with support for custom Oppia tags. + * + * @param rawString raw HTML to parse + * @param htmlContentTextView the [TextView] that will contain the returned [Spannable] + * @param supportsLinks whether the provided [TextView] should support link forwarding (it's + * recommended not to use this for [TextView]s that are within other layouts that need to + * support clicking (default false) + * @return a [Spannable] representing the styled text. */ - fun parseOppiaHtml(rawString: String, htmlContentTextView: TextView): Spannable { + fun parseOppiaHtml( + rawString: String, + htmlContentTextView: TextView, + supportsLinks: Boolean = false, + supportsConceptCards: Boolean = false + ): Spannable { var htmlContent = rawString - if (htmlContent.contains("\n\t")) { + if ("\n\t" in htmlContent) { htmlContent = htmlContent.replace("\n\t", "") } - if (htmlContent.contains("\n\n")) { + if ("\n\n" in htmlContent) { htmlContent = htmlContent.replace("\n\n", "") } + if ("
  • " in htmlContent) { + htmlContent = htmlContent.replace("
  • ", "<$CUSTOM_BULLET_LIST_TAG>") + .replace("
  • ", "") + } - if (htmlContent.contains(CUSTOM_IMG_TAG)) { - htmlContent = htmlContent.replace( - CUSTOM_IMG_TAG, - REPLACE_IMG_TAG, - /* ignoreCase= */ false - ) - htmlContent = htmlContent.replace( - CUSTOM_IMG_FILE_PATH_ATTRIBUTE, - REPLACE_IMG_FILE_PATH_ATTRIBUTE, - /* ignoreCase= */ false - ) + if (CUSTOM_IMG_TAG in htmlContent) { + htmlContent = htmlContent.replace(CUSTOM_IMG_TAG, REPLACE_IMG_TAG) + htmlContent = + htmlContent.replace(CUSTOM_IMG_FILE_PATH_ATTRIBUTE, REPLACE_IMG_FILE_PATH_ATTRIBUTE) htmlContent = htmlContent.replace("&quot;", "") } + // https://stackoverflow.com/a/8662457 + if (supportsLinks) { + htmlContentTextView.movementMethod = LinkMovementMethod.getInstance() + } + val imageGetter = urlImageParserFactory.create( htmlContentTextView, gcsResourceName, entityType, entityId, imageCenterAlign ) - - val htmlSpannable = HtmlCompat.fromHtml( - htmlContent, - HtmlCompat.FROM_HTML_MODE_LEGACY, - imageGetter, - LiTagHandler() - ) as Spannable + val htmlSpannable = CustomHtmlContentHandler.fromHtml( + htmlContent, imageGetter, computeCustomTagHandlers(supportsConceptCards) + ) val spannableBuilder = SpannableStringBuilder(htmlSpannable) val bulletSpans = spannableBuilder.getSpans( @@ -79,41 +95,83 @@ class HtmlParser private constructor( Spanned.SPAN_INCLUSIVE_EXCLUSIVE ) } + return trimSpannable(spannableBuilder) } - private fun trimSpannable(spannable: SpannableStringBuilder): SpannableStringBuilder { - var trimStart = 0 - var trimEnd = 0 - - var text = spannable.toString() - - if (text.startsWith("\n")) { - text = text.substring(1) - trimStart += 1 + private fun computeCustomTagHandlers( + supportsConceptCards: Boolean + ): Map { + val handlersMap = mutableMapOf() + handlersMap[CUSTOM_BULLET_LIST_TAG] = bulletTagHandler + if (supportsConceptCards) { + handlersMap[CUSTOM_CONCEPT_CARD_TAG] = conceptCardTagHandler } + return handlersMap + } - if (text.endsWith("\n")) { - text = text.substring(0, text.length - 1) - trimEnd += 2 + // https://mohammedlakkadshaw.com/blog/handling-custom-tags-in-android-using-html-taghandler.html/ + private class ConceptCardTagHandler( + private val customOppiaTagActionListener: CustomOppiaTagActionListener? + ) : CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + // Replace the custom tag with a clickable piece of text based on the tag's customizations. + val skillId = attributes.getValue("skill_id-with-value") + val text = attributes.getValue("text-with-value") + val spannableBuilder = SpannableStringBuilder(text) + spannableBuilder.setSpan( + object : ClickableSpan() { + override fun onClick(view: View) { + customOppiaTagActionListener?.onConceptCardLinkClicked(view, skillId) + } + }, + 0, text.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + output.replace(openIndex, closeIndex, spannableBuilder) } + } + private fun trimSpannable(spannable: SpannableStringBuilder): SpannableStringBuilder { + val trimmedText = spannable.toString() + val trimStart = if (trimmedText.startsWith("\n")) 1 else 0 + val trimEnd = if (trimmedText.length > 1 && trimmedText.endsWith("\n")) 2 else 0 return spannable.delete(0, trimStart).delete(spannable.length - trimEnd, spannable.length) } + /** Listener that's called when a custom tag triggers an event. */ + interface CustomOppiaTagActionListener { + /** + * Called when an embedded concept card link is clicked in the specified view with the skillId + * corresponding to the card that should be shown. + */ + fun onConceptCardLinkClicked(view: View, skillId: String) + } + + /** Factory for creating new [HtmlParser]s. */ class Factory @Inject constructor(private val urlImageParserFactory: UrlImageParser.Factory) { + /** + * Returns a new [HtmlParser] with the specified entity type and ID for loading images, and an + * optionally specified [CustomOppiaTagActionListener] for handling custom Oppia tag events. + */ fun create( gcsResourceName: String, entityType: String, entityId: String, - imageCenterAlign: Boolean + imageCenterAlign: Boolean, + customOppiaTagActionListener: CustomOppiaTagActionListener? = null ): HtmlParser { return HtmlParser( urlImageParserFactory, gcsResourceName, entityType, entityId, - imageCenterAlign + imageCenterAlign, + customOppiaTagActionListener ) } } diff --git a/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt b/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt deleted file mode 100755 index f526098a8bb..00000000000 --- a/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.oppia.util.parser - -import android.text.Editable -import android.text.Html -import android.text.Spannable -import android.text.Spanned -import android.text.style.BulletSpan -import org.xml.sax.XMLReader - -/** - * [Html.TagHandler] implementation that processes
  • tags and creates bullets. - * - * Reference: https://github.com/davidbilik/bullet-span-sample - */ -class LiTagHandler : Html.TagHandler { - /** - * Helper marker class. Based on [Html.fromHtml] implementation. - */ - class Bullet - - override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { - if (tag == "li" && opening) { - output.setSpan(Bullet(), output.length, output.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) - } - if (tag == "li" && !opening) { - output.append("\n") - val lastMark = output.getSpans(0, output.length, Bullet::class.java).lastOrNull() - lastMark?.let { - val start = output.getSpanStart(it) - output.removeSpan(it) - if (start != output.length) { - output.setSpan(BulletSpan(), start, output.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - } - } - } - } -} diff --git a/utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt b/utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt new file mode 100644 index 00000000000..f2aa34f6eeb --- /dev/null +++ b/utility/src/test/java/org/oppia/util/parser/CustomHtmlContentHandlerTest.kt @@ -0,0 +1,174 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Html +import android.text.Spannable +import android.text.style.BulletSpan +import android.text.style.StyleSpan +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.anyString +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.robolectric.annotation.LooperMode +import org.xml.sax.Attributes +import kotlin.reflect.KClass + +/** Tests for [CustomHtmlContentHandler]. */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +class CustomHtmlContentHandlerTest { + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Mock lateinit var mockImageGetter: Html.ImageGetter + + @Test + fun testParseHtml_emptyString_returnsEmptyString() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "", imageGetter = mockImageGetter, customTagHandlers = mapOf() + ) + + assertThat(parsedHtml.length).isEqualTo(0) + } + + @Test + fun testParseHtml_standardBoldHtml_returnsStringWithBoldSpan() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "Text", imageGetter = mockImageGetter, customTagHandlers = mapOf() + ) + + assertThat(parsedHtml.toString()).isEqualTo("Text") + assertThat(parsedHtml.getSpansFromWholeString(StyleSpan::class)).hasLength(1) + } + + @Test + fun testParseHtml_withImage_callsImageGetter() { + CustomHtmlContentHandler.fromHtml( + html = "", + imageGetter = mockImageGetter, + customTagHandlers = mapOf() + ) + + verify(mockImageGetter).getDrawable(anyString()) + } + + @Test + fun testParseHtml_withOneCustomTag_handlerIsCalledWithAttributes() { + val fakeTagHandler = FakeTagHandler() + + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf("custom-tag" to fakeTagHandler) + ) + + assertThat(fakeTagHandler.handleTagCalled).isTrue() + assertThat(fakeTagHandler.attributes.getValue("custom-attribute")).isEqualTo("value") + assertThat(parsedHtml.toString()).isEqualTo("content") + } + + @Test + fun testParseHtml_withOneCustomTag_missingHandler_keepsContent() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf() + ) + + assertThat(parsedHtml.toString()).isEqualTo("content") + } + + @Test + fun testParseHtml_withOneCustomTag_handlerReplacesText_correctlyUpdatesText() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf( + "custom-tag" to ReplacingTagHandler("custom-attribute") + ) + ) + + // Verify that handlers which wish to replace text can successfully do so. + assertThat(parsedHtml.toString()).isEqualTo("value") + } + + @Test + fun testParseHtml_withNestedTags_successfullyParsesBoth() { + val outerFakeTagHandler = FakeTagHandler() + val innerFakeTagHandler = FakeTagHandler() + + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = "some other content", + imageGetter = mockImageGetter, + customTagHandlers = mapOf( + "outer-tag" to outerFakeTagHandler, + "inner-tag" to innerFakeTagHandler + ) + ) + + // Verify that both tag handlers are called (showing support for nesting). + assertThat(outerFakeTagHandler.handleTagCalled).isTrue() + assertThat(innerFakeTagHandler.handleTagCalled).isTrue() + assertThat(parsedHtml.toString()).isEqualTo("some other content") + } + + @Test + fun testCustomListElement_betweenParagraphs_parsesCorrectlyIntoBulletSpan() { + val htmlString = "

    Paragraph 1

      Item

    Paragraph 2.

    " + + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = htmlString, imageGetter = mockImageGetter, + customTagHandlers = mapOf( + CUSTOM_BULLET_LIST_TAG to BulletTagHandler() + ) + ) + + assertThat(parsedHtml.toString()).isNotEmpty() + assertThat(parsedHtml.getSpansFromWholeString(BulletSpan::class)).hasLength(1) + } + + private fun Spannable.getSpansFromWholeString(spanClass: KClass): Array = + getSpans(/* start= */ 0, /* end= */ length, spanClass.javaObjectType) + + private class FakeTagHandler : CustomHtmlContentHandler.CustomTagHandler { + var handleTagCalled = false + lateinit var attributes: Attributes + + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + handleTagCalled = true + this.attributes = attributes + } + } + + private class ReplacingTagHandler( + private val attributeTextToReplaceWith: String + ) : CustomHtmlContentHandler.CustomTagHandler { + override fun handleTag( + attributes: Attributes, + openIndex: Int, + closeIndex: Int, + output: Editable + ) { + output.replace(openIndex, closeIndex, attributes.getValue(attributeTextToReplaceWith)) + } + } +}