diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7398e376a00..5128677c47d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:supportsRtl="true" android:theme="@style/OppiaTheme"> + diff --git a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt index 94a16331fa5..d12e457a66d 100644 --- a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt +++ b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt @@ -12,6 +12,7 @@ import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.profile.ProfileActivity import org.oppia.app.story.StoryActivity import org.oppia.app.testing.BindableAdapterTestActivity +import org.oppia.app.testing.ContentCardTestActivity import org.oppia.app.testing.HtmlParserTestActivity import org.oppia.app.topic.TopicActivity import org.oppia.app.topic.questionplayer.QuestionPlayerActivity @@ -32,6 +33,7 @@ interface ActivityComponent { fun inject(audioFragmentTestActivity: AudioFragmentTestActivity) fun inject(bindableAdapterTestActivity: BindableAdapterTestActivity) fun inject(conceptCardFragmentTestActivity: ConceptCardFragmentTestActivity) + fun inject(contentCardTestActivity: ContentCardTestActivity) fun inject(explorationActivity: ExplorationActivity) fun inject(homeActivity: HomeActivity) fun inject(htmlParserTestActivty: HtmlParserTestActivity) 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 2c2a18900bd..0a1083c5012 100644 --- a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt +++ b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt @@ -16,6 +16,7 @@ import org.oppia.domain.classify.rules.textinput.TextInputRuleModule 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.DispatcherModule import javax.inject.Provider @@ -27,7 +28,8 @@ import javax.inject.Singleton ApplicationModule::class, DispatcherModule::class, NetworkModule::class, LoggerModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, InteractionsModule::class, - GcsResourceModule::class, ImageParsingModule::class, GlideImageLoaderModule::class + GcsResourceModule::class, ImageParsingModule::class, GlideImageLoaderModule::class, + HtmlParserEntityTypeModule::class ]) interface ApplicationComponent { @Component.Builder diff --git a/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt b/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt old mode 100644 new mode 100755 index 411bbb39ab8..9c606fcde33 --- a/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt @@ -1,16 +1,21 @@ package org.oppia.app.player.state +import android.text.Spannable import android.view.LayoutInflater import android.view.ViewGroup import androidx.databinding.DataBindingUtil import androidx.databinding.ViewDataBinding import androidx.recyclerview.widget.RecyclerView import androidx.databinding.library.baseAdapters.BR +import kotlinx.android.synthetic.main.content_item.view.* import kotlinx.android.synthetic.main.state_button_item.view.* import org.oppia.app.R +import org.oppia.app.databinding.ContentItemBinding import org.oppia.app.player.state.itemviewmodel.StateButtonViewModel import org.oppia.app.player.state.listener.ButtonInteractionListener import org.oppia.app.databinding.StateButtonItemBinding +import org.oppia.app.player.state.itemviewmodel.ContentViewModel +import org.oppia.util.parser.HtmlParser @Suppress("unused") private const val VIEW_TYPE_CONTENT = 1 @@ -25,7 +30,10 @@ private const val VIEW_TYPE_STATE_BUTTON = 5 /** Adapter to inflate different items/views inside [RecyclerView]. The itemList consists of various ViewModels. */ class StateAdapter( private val itemList: MutableList, - private val buttonInteractionListener: ButtonInteractionListener + private val buttonInteractionListener: ButtonInteractionListener, + private val htmlParserFactory: HtmlParser.Factory, + private val entityType: String, + private val explorationId: String ) : RecyclerView.Adapter() { @@ -45,7 +53,18 @@ class StateAdapter( ) StateButtonViewHolder(binding, buttonInteractionListener) } - else -> throw IllegalArgumentException("Invalid view type") as Throwable + VIEW_TYPE_CONTENT -> { + val inflater = LayoutInflater.from(parent.context) + val binding = + DataBindingUtil.inflate( + inflater, + R.layout.content_item, + parent, + /* attachToParent= */ false + ) + ContentViewHolder(binding) + } + else -> throw IllegalArgumentException("Invalid view type") } } @@ -54,11 +73,15 @@ class StateAdapter( VIEW_TYPE_STATE_BUTTON -> { (holder as StateButtonViewHolder).bind(itemList[position] as StateButtonViewModel) } + VIEW_TYPE_CONTENT -> { + (holder as ContentViewHolder).bind((itemList[position] as ContentViewModel).htmlContent) + } } } override fun getItemViewType(position: Int): Int { return when (itemList[position]) { + is ContentViewModel -> VIEW_TYPE_CONTENT is StateButtonViewModel -> { stateButtonViewModel = itemList[position] as StateButtonViewModel VIEW_TYPE_STATE_BUTTON @@ -71,6 +94,18 @@ class StateAdapter( return itemList.size } + inner class ContentViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { + internal fun bind(rawString: String) { + binding.setVariable(BR.htmlContent, rawString) + binding.executePendingBindings() + val htmlResult: Spannable = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( + rawString, + binding.root.content_text_view + ) + binding.root.content_text_view.text = htmlResult + } + } + private class StateButtonViewHolder( val binding: ViewDataBinding, private val buttonInteractionListener: ButtonInteractionListener 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 032a6ec739f..5c38516e94e 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 @@ -17,9 +17,11 @@ import org.oppia.app.model.AnswerOutcome import org.oppia.app.model.CellularDataPreference import org.oppia.app.model.EphemeralState import org.oppia.app.model.InteractionObject +import org.oppia.app.model.SubtitledHtml import org.oppia.app.player.audio.AudioFragment import org.oppia.app.player.audio.CellularDataDialogFragment import org.oppia.app.player.exploration.ExplorationActivity +import org.oppia.app.player.state.itemviewmodel.ContentViewModel import org.oppia.app.player.state.itemviewmodel.StateButtonViewModel import org.oppia.app.player.state.listener.ButtonInteractionListener import org.oppia.app.viewmodel.ViewModelProvider @@ -28,6 +30,8 @@ import org.oppia.domain.exploration.ExplorationDataController import org.oppia.domain.exploration.ExplorationProgressController import org.oppia.util.data.AsyncResult import org.oppia.util.logging.Logger +import org.oppia.util.parser.ExplorationHtmlParserEntityType +import org.oppia.util.parser.HtmlParser import javax.inject.Inject const val STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY = "STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY" @@ -52,6 +56,7 @@ private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." /** The presenter for [StateFragment]. */ @FragmentScope class StateFragmentPresenter @Inject constructor( + @ExplorationHtmlParserEntityType private val entityType: String, private val activity: AppCompatActivity, private val fragment: Fragment, private val cellularDialogController: CellularDialogController, @@ -59,7 +64,8 @@ class StateFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val explorationDataController: ExplorationDataController, private val explorationProgressController: ExplorationProgressController, - private val logger: Logger + private val logger: Logger, + private val htmlParserFactory: HtmlParser.Factory ) : ButtonInteractionListener { private var showCellularDataDialog = true @@ -90,9 +96,9 @@ class StateFragmentPresenter @Inject constructor( useCellularData = prefs.useCellularData } }) - - stateAdapter = StateAdapter(itemList, this as ButtonInteractionListener) - + explorationId = fragment.arguments!!.getString(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)!! + stateAdapter = + StateAdapter(itemList, this as ButtonInteractionListener, htmlParserFactory, entityType, explorationId) binding = StateFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) binding.stateRecyclerView.apply { adapter = stateAdapter @@ -101,7 +107,6 @@ class StateFragmentPresenter @Inject constructor( it.stateFragment = fragment as StateFragment it.viewModel = getStateViewModel() } - explorationId = checkNotNull(fragment.arguments!!.getString(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)) subscribeToCurrentState() @@ -171,7 +176,7 @@ class StateFragmentPresenter @Inject constructor( ephemeralStateLiveData.observe(fragment, Observer { result -> itemList.clear() currentEphemeralState = result - + checkAndAddContentItem() updateDummyStateName() val interactionId = result.state.interaction.id @@ -328,6 +333,23 @@ class StateFragmentPresenter @Inject constructor( oldStateNameList.add(currentEphemeralState.state.name) } } + + private fun checkAndAddContentItem() { + if (currentEphemeralState.state.hasContent()) { + addContentItem() + } else { + logger.e("StateFragment", "checkAndAddContentItem: State does not have content.") + } + } + + private fun addContentItem() { + val contentViewModel = ContentViewModel() + val contentSubtitledHtml: SubtitledHtml = currentEphemeralState.state.content + contentViewModel.contentId = contentSubtitledHtml.contentId + contentViewModel.htmlContent = contentSubtitledHtml.html + itemList.add(contentViewModel) + stateAdapter.notifyDataSetChanged() + } private fun updateNavigationButtonVisibility( interactionId: String, 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 new file mode 100755 index 00000000000..8606d70ae38 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt @@ -0,0 +1,12 @@ +package org.oppia.app.player.state.itemviewmodel + +import androidx.lifecycle.ViewModel +import org.oppia.app.fragment.FragmentScope +import javax.inject.Inject + +/** [ViewModel] for content-card state. */ +@FragmentScope +class ContentViewModel @Inject constructor() : ViewModel() { + var contentId = "" + var htmlContent = "" +} diff --git a/app/src/main/java/org/oppia/app/testing/ContentCardTestActivity.kt b/app/src/main/java/org/oppia/app/testing/ContentCardTestActivity.kt new file mode 100644 index 00000000000..fe9d06058a1 --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/ContentCardTestActivity.kt @@ -0,0 +1,17 @@ +package org.oppia.app.testing + +import android.os.Bundle +import org.oppia.app.activity.InjectableAppCompatActivity +import javax.inject.Inject + +/** Activity to test the functionality of content-card used in [StateFragment]. */ +class ContentCardTestActivity : InjectableAppCompatActivity() { + @Inject + lateinit var contentCardTestPresenter: ContentCardTestActivityPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activityComponent.inject(this) + contentCardTestPresenter.handleOnCreate() + } +} diff --git a/app/src/main/java/org/oppia/app/testing/ContentCardTestActivityPresenter.kt b/app/src/main/java/org/oppia/app/testing/ContentCardTestActivityPresenter.kt new file mode 100644 index 00000000000..0357856bd11 --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/ContentCardTestActivityPresenter.kt @@ -0,0 +1,53 @@ +package org.oppia.app.testing + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import org.oppia.app.R +import org.oppia.app.activity.ActivityScope +import org.oppia.app.player.state.StateFragment +import org.oppia.domain.exploration.ExplorationDataController +import org.oppia.domain.exploration.TEST_EXPLORATION_ID_5 +import org.oppia.util.data.AsyncResult +import org.oppia.util.logging.Logger +import javax.inject.Inject + +private const val EXPLORATION_ID = TEST_EXPLORATION_ID_5 + +/** The presenter for [ContentCardTestActivity]. */ +@ActivityScope +class ContentCardTestActivityPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val explorationDataController: ExplorationDataController, + private val logger: Logger +) { + fun handleOnCreate() { + activity.setContentView(R.layout.content_card_test_activity) + loadDummyExplorationAtStart() + } + + private fun getStateFragment(): StateFragment? { + return activity.supportFragmentManager.findFragmentById(R.id.state_fragment_placeholder) as StateFragment? + } + + private fun loadDummyExplorationAtStart() { + explorationDataController.startPlayingExploration( + EXPLORATION_ID + ).observe(activity, Observer> { result -> + when { + result.isPending() -> logger.d("ContentCardTest", "Loading exploration") + result.isFailure() -> logger.e("ContentCardTest", "Failed to load exploration", result.getErrorOrNull()!!) + else -> { + logger.d("ContentCardTest", "Successfully loaded exploration") + + if (getStateFragment() == null) { + val stateFragment = StateFragment.newInstance(EXPLORATION_ID) + activity.supportFragmentManager.beginTransaction().add( + R.id.state_fragment_placeholder, + stateFragment + ).commitNow() + } + } + } + }) + } +} diff --git a/app/src/main/res/layout/content_card_test_activity.xml b/app/src/main/res/layout/content_card_test_activity.xml new file mode 100644 index 00000000000..74477fcf688 --- /dev/null +++ b/app/src/main/res/layout/content_card_test_activity.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/layout/content_item.xml b/app/src/main/res/layout/content_item.xml new file mode 100755 index 00000000000..5c410682691 --- /dev/null +++ b/app/src/main/res/layout/content_item.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + 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 5dc9e2b48ed..c5ef4ce8276 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 @@ -23,6 +23,7 @@ import dagger.Component import dagger.Module import dagger.Provides import kotlinx.coroutines.CoroutineDispatcher +import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.not import org.junit.After import org.junit.Before @@ -35,6 +36,7 @@ import org.oppia.app.player.exploration.ExplorationActivity import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPosition import org.oppia.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView +import org.oppia.app.testing.ContentCardTestActivity import org.oppia.util.threading.BackgroundDispatcher import org.oppia.util.threading.BlockingDispatcher import javax.inject.Singleton @@ -364,6 +366,16 @@ class StateFragmentTest { intended(hasComponent(HomeActivity::class.java.name)) } + @Test + fun testContentCard_forDemoExploration_withCustomOppiaTags_displaysParsedHtml() { + ActivityScenario.launch(ContentCardTestActivity::class.java).use { + val htmlResult = + "Hi, welcome to Oppia! is a tool that helps you create interactive learning activities that can be continually improved over time.\n\n" + + "Incidentally, do you know where the name 'Oppia' comes from?" + onView(atPositionOnView(R.id.state_recycler_view, 0,R.id.content_text_view)).check(matches(hasDescendant(withText(htmlResult)))) + } + } + @After fun tearDown() { Intents.release() diff --git a/utility/src/main/java/org/oppia/util/parser/ExplorationHtmlParserEntityType.kt b/utility/src/main/java/org/oppia/util/parser/ExplorationHtmlParserEntityType.kt new file mode 100755 index 00000000000..779fbc5e55e --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/ExplorationHtmlParserEntityType.kt @@ -0,0 +1,7 @@ +package org.oppia.util.parser + +import javax.inject.Qualifier + +/** Qualifier for injecting the entity type for exploration. */ +@Qualifier +annotation class ExplorationHtmlParserEntityType diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParserEntityTypeModule.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParserEntityTypeModule.kt new file mode 100755 index 00000000000..bb33a097778 --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParserEntityTypeModule.kt @@ -0,0 +1,14 @@ +package org.oppia.util.parser + +import dagger.Module +import dagger.Provides + +/** Provides Html parsing entity type dependencies. */ +@Module +class HtmlParserEntityTypeModule { + @Provides + @ExplorationHtmlParserEntityType + fun provideExplorationHtmlParserEntityType(): String { + return "exploration" + } +}