diff --git a/app/BUILD.bazel b/app/BUILD.bazel
index b2317c099fa..ba205a1040c 100644
--- a/app/BUILD.bazel
+++ b/app/BUILD.bazel
@@ -155,6 +155,7 @@ LISTENERS = [
"src/main/java/org/oppia/android/app/recyclerview/OnItemDragListener.kt",
"src/main/java/org/oppia/android/app/settings/profile/LoadProfileEditDeletionDialogListener.kt",
"src/main/java/org/oppia/android/app/settings/profile/RouteToProfileEditListener.kt",
+ "src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt",
"src/main/java/org/oppia/android/app/topic/RouteToRevisionCardListener.kt",
"src/main/java/org/oppia/android/app/topic/lessons/ChapterSummarySelector.kt",
"src/main/java/org/oppia/android/app/topic/lessons/StorySummarySelector.kt",
@@ -209,6 +210,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt",
"src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt",
"src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryViewModel.kt",
+ "src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt",
"src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt",
"src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt",
"src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt",
@@ -235,11 +237,18 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt",
"src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt",
"src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt",
"src/main/java/org/oppia/android/app/topic/TopicViewModel.kt",
"src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt",
"src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt",
"src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt",
"src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt",
+ "src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt",
"src/main/java/org/oppia/android/app/utility/RatioExtensions.kt",
"src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt",
]
@@ -342,6 +351,7 @@ VIEW_MODELS = [
"src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt",
"src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt",
"src/main/java/org/oppia/android/app/story/StoryViewModel.kt",
+ "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt",
"src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt",
"src/main/java/org/oppia/android/app/testing/BindableAdapterTestViewModel.kt",
"src/main/java/org/oppia/android/app/testing/CircularProgressIndicatorAdaptersTestViewModel.kt",
@@ -356,7 +366,6 @@ VIEW_MODELS = [
"src/main/java/org/oppia/android/app/topic/practice/TopicPracticeViewModel.kt",
"src/main/java/org/oppia/android/app/topic/revision/revisionitemviewmodel/TopicRevisionItemViewModel.kt",
"src/main/java/org/oppia/android/app/topic/revision/TopicRevisionViewModel.kt",
- "src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt",
"src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalViewModel.kt",
"src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicHeaderViewModel.kt",
"src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicItemViewModel.kt",
@@ -402,7 +411,10 @@ VIEWS_WITH_RESOURCE_IMPORTS = [
"src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt",
"src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt",
"src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt",
+ "src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt",
"src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt",
+ "src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt",
+ "src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt",
"src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt",
]
@@ -621,6 +633,7 @@ kt_android_library(
"//domain/src/main/java/org/oppia/android/domain/audio:cellular_audio_dialog_controller",
"//model/src/main/proto:arguments_java_proto_lite",
"//model/src/main/proto:question_java_proto_lite",
+ "//model/src/main/proto:survey_java_proto_lite",
"//model/src/main/proto:topic_java_proto_lite",
"//third_party:androidx_recyclerview_recyclerview",
],
@@ -781,6 +794,8 @@ kt_android_library(
"//domain/src/main/java/org/oppia/android/domain/oppialogger:startup_listener",
"//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller",
"//domain/src/main/java/org/oppia/android/domain/spotlight:spotlight_state_controller",
+ "//domain/src/main/java/org/oppia/android/domain/survey:gating_controller",
+ "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
"//model/src/main/proto:arguments_java_proto_lite",
"//third_party:androidx_databinding_databinding-adapters",
"//third_party:androidx_databinding_databinding-common",
diff --git a/app/build.gradle b/app/build.gradle
index 34aeccd28b1..71b759c6475 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -210,6 +210,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',
+ 'androidx.test.ext:truth:1.4.0',
'com.github.bumptech.glide:mocks:4.11.0',
'com.google.truth:truth:1.1.3',
'androidx.work:work-testing:2.4.0',
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1f1fc369830..a27551a2005 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -307,6 +307,11 @@
+
(this) as ViewComponentFactory
+ val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
+ viewComponent.inject(this)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt
index 36b83c8244d..d89e5b1297b 100644
--- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt
+++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt
@@ -65,6 +65,10 @@ import org.oppia.android.app.shim.IntentFactoryShimModule
import org.oppia.android.app.shim.ViewBindingShimModule
import org.oppia.android.app.spotlight.SpotlightFragment
import org.oppia.android.app.story.StoryFragment
+import org.oppia.android.app.survey.ExitSurveyConfirmationDialogFragment
+import org.oppia.android.app.survey.SurveyFragment
+import org.oppia.android.app.survey.SurveyOutroDialogFragment
+import org.oppia.android.app.survey.SurveyWelcomeDialogFragment
import org.oppia.android.app.testing.DragDropTestFragment
import org.oppia.android.app.testing.ExplorationTestActivityPresenter
import org.oppia.android.app.testing.ImageRegionSelectionTestFragment
@@ -177,4 +181,8 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto
fun inject(walkthroughFinalFragment: WalkthroughFinalFragment)
fun inject(walkthroughTopicListFragment: WalkthroughTopicListFragment)
fun inject(walkthroughWelcomeFragment: WalkthroughWelcomeFragment)
+ fun inject(surveyFragment: SurveyFragment)
+ fun inject(exitSurveyConfirmationDialogFragment: ExitSurveyConfirmationDialogFragment)
+ fun inject(surveyWelcomeDialogFragment: SurveyWelcomeDialogFragment)
+ fun inject(surveyOutroDialogFragment: SurveyOutroDialogFragment)
}
diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt
index c7581956509..670afb8c3c5 100644
--- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt
@@ -92,7 +92,7 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor(
HintsAndSolutionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
binding.hintsAndSolutionToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp)
binding.hintsAndSolutionToolbar.setNavigationContentDescription(
- R.string.hints_andSolution_close_icon_description
+ R.string.hints_and_solution_close_icon_description
)
binding.hintsAndSolutionToolbar.setNavigationOnClickListener {
(fragment.requireActivity() as? HintsAndSolutionListener)?.dismiss()
diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt
deleted file mode 100644
index 61bcf6bf5e6..00000000000
--- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package org.oppia.android.app.home.recentlyplayed
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import org.oppia.android.databinding.RecentlyPlayedStoryCardBinding
-import org.oppia.android.databinding.SectionTitleBinding
-
-private const val VIEW_TYPE_SECTION_TITLE_TEXT = 1
-private const val VIEW_TYPE_SECTION_STORY_ITEM = 2
-
-/**
- * Adapter to inflate different items/views inside [RecyclerView] for Ongoing Story List.
- *
- * @property itemList the items that may be displayed in [RecentlyPlayedFragment]'s recycler view
- */
-class PromotedStoryListAdapter(
- private val itemList: MutableList
-) : RecyclerView.Adapter() {
-
- private var titleIndex: Int = 0
- private var storyGridPosition: Int = 0
- private var spanCount = 0
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
- return when (viewType) {
- // TODO(#632): Generalize this binding to make adding future items easier.
- VIEW_TYPE_SECTION_TITLE_TEXT -> {
- val inflater = LayoutInflater.from(parent.context)
- val binding =
- SectionTitleBinding.inflate(
- inflater,
- parent,
- /* attachToParent= */ false
- )
- SectionTitleViewHolder(binding)
- }
- VIEW_TYPE_SECTION_STORY_ITEM -> {
- val inflater = LayoutInflater.from(parent.context)
- val binding =
- RecentlyPlayedStoryCardBinding.inflate(
- inflater,
- parent,
- /* attachToParent= */ false
- )
- PromotedStoryViewHolder(binding)
- }
- else -> throw IllegalArgumentException("Invalid view type: $viewType")
- }
- }
-
- override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) =
- when (holder.itemViewType) {
- VIEW_TYPE_SECTION_TITLE_TEXT -> {
- titleIndex = position
- (holder as SectionTitleViewHolder).bind(itemList[position] as SectionTitleViewModel)
- }
- VIEW_TYPE_SECTION_STORY_ITEM -> {
- storyGridPosition = position - titleIndex
- (holder as PromotedStoryViewHolder).bind(itemList[position] as PromotedStoryViewModel)
- }
- else -> throw IllegalArgumentException("Invalid item view type: ${holder.itemViewType}")
- }
-
- override fun getItemViewType(position: Int): Int {
- return when (itemList[position]) {
- is SectionTitleViewModel -> {
- VIEW_TYPE_SECTION_TITLE_TEXT
- }
- is PromotedStoryViewModel -> {
- VIEW_TYPE_SECTION_STORY_ITEM
- }
- else -> throw IllegalArgumentException(
- "Invalid type of data $position with item ${itemList[position]}"
- )
- }
- }
-
- override fun getItemCount(): Int {
- return itemList.size
- }
-
-/**
- * Specifies the number of columns of recently played stories shown in the recently played stories
- * list.
- *
- * @param spanCount specifies the number of spaces this item should occupy, based on screen size
- */
- fun setSpanCount(spanCount: Int) {
- this.spanCount = spanCount
- }
-
- private class SectionTitleViewHolder(
- val binding: SectionTitleBinding
- ) : RecyclerView.ViewHolder(binding.root) {
- /** Binds the view model that sets section titles. */
- fun bind(sectionTitleViewModel: SectionTitleViewModel) {
- binding.viewModel = sectionTitleViewModel
- }
- }
-
- private class PromotedStoryViewHolder(
- val binding: RecentlyPlayedStoryCardBinding
- ) : RecyclerView.ViewHolder(binding.root) {
- /** Binds the view model that sets recently played items. */
- fun bind(promotedStoryViewModel: PromotedStoryViewModel) {
- binding.viewModel = promotedStoryViewModel
- }
- }
-}
diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt
index 5bc07a28483..fcd8a8b3c7c 100755
--- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt
@@ -5,9 +5,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
-import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
-import androidx.lifecycle.Transformations
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oppia.android.R
@@ -17,19 +15,17 @@ import org.oppia.android.app.model.ChapterPlayState
import org.oppia.android.app.model.ExplorationActivityParams
import org.oppia.android.app.model.ExplorationCheckpoint
import org.oppia.android.app.model.ProfileId
-import org.oppia.android.app.model.PromotedActivityList
import org.oppia.android.app.model.PromotedStory
+import org.oppia.android.app.recyclerview.BindableAdapter
import org.oppia.android.app.topic.RouteToResumeLessonListener
-import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.databinding.RecentlyPlayedFragmentBinding
+import org.oppia.android.databinding.RecentlyPlayedStoryCardBinding
+import org.oppia.android.databinding.SectionTitleBinding
import org.oppia.android.domain.exploration.ExplorationDataController
import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController
import org.oppia.android.domain.oppialogger.OppiaLogger
-import org.oppia.android.domain.topic.TopicListController
-import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
-import org.oppia.android.util.parser.html.StoryHtmlParserEntityType
import javax.inject.Inject
/** The presenter for [RecentlyPlayedFragment]. */
@@ -39,11 +35,9 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
private val fragment: Fragment,
private val oppiaLogger: OppiaLogger,
private val explorationDataController: ExplorationDataController,
- private val topicListController: TopicListController,
private val explorationCheckpointController: ExplorationCheckpointController,
- @StoryHtmlParserEntityType private val entityType: String,
- private val resourceHandler: AppLanguageResourceHandler,
- private val translationController: TranslationController
+ private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory,
+ private val recentlyPlayedViewModelFactory: RecentlyPlayedViewModel.Factory
) {
private val routeToResumeLessonListener = activity as RouteToResumeLessonListener
@@ -51,178 +45,40 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
private lateinit var profileId: ProfileId
private lateinit var binding: RecentlyPlayedFragmentBinding
- private lateinit var promotedStoryListAdapter: PromotedStoryListAdapter
- private val itemList: MutableList = ArrayList()
fun handleCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
- profileId: ProfileId
+ internalProfileId: Int
): View? {
- binding = RecentlyPlayedFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
-
- this.profileId = profileId
-
- promotedStoryListAdapter = PromotedStoryListAdapter(itemList)
- binding.ongoingStoryRecyclerView.apply {
- adapter = promotedStoryListAdapter
- }
- binding.lifecycleOwner = fragment
-
- subscribeToPromotedStoryList()
- return binding.root
- }
-
- private val promotedStoryListSummaryResultLiveData:
- LiveData>
- by lazy {
- topicListController.getPromotedActivityList(
- profileId
- ).toLiveData()
- }
-
- private fun subscribeToPromotedStoryList() {
- getAssumedSuccessfulPromotedActivityList().observe(
- fragment,
- {
- if (it.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) {
- addRecentlyPlayedStoryListSection(it.promotedStoryList.recentlyPlayedStoryList)
- }
-
- if (it.promotedStoryList.olderPlayedStoryList.isNotEmpty()) {
- addOlderStoryListSection(it.promotedStoryList.olderPlayedStoryList)
- }
-
- if (it.promotedStoryList.suggestedStoryList.isNotEmpty()) {
- addRecommendedStoryListSection(it.promotedStoryList.suggestedStoryList)
- }
-
- binding.ongoingStoryRecyclerView.layoutManager =
- createLayoutManager(
- it.promotedStoryList.recentlyPlayedStoryCount,
- it.promotedStoryList.olderPlayedStoryCount,
- it.promotedStoryList.suggestedStoryCount
- )
- promotedStoryListAdapter.notifyDataSetChanged()
- }
- )
- }
-
- private fun addRecentlyPlayedStoryListSection(
- recentlyPlayedStoryList: MutableList
- ) {
- itemList.clear()
- val recentSectionTitleViewModel =
- SectionTitleViewModel(
- resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false
- )
- itemList.add(recentSectionTitleViewModel)
- recentlyPlayedStoryList.forEachIndexed { index, promotedStory ->
- val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
- itemList.add(ongoingStoryViewModel)
- }
- }
-
- private fun createOngoingStoryViewModel(
- promotedStory: PromotedStory,
- index: Int
- ): RecentlyPlayedItemViewModel {
- return PromotedStoryViewModel(
- activity,
- promotedStory,
- entityType,
+ this.profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build()
+ val recentlyPlayedViewModel = recentlyPlayedViewModelFactory.create(
fragment as PromotedStoryClickListener,
- index,
- resourceHandler,
- translationController
+ this.profileId
)
- }
-
- private fun addOlderStoryListSection(olderPlayedStoryList: List) {
- val showDivider = itemList.isNotEmpty()
- val olderSectionTitleViewModel =
- SectionTitleViewModel(
- resourceHandler.getStringInLocale(R.string.ongoing_story_last_month),
- showDivider
- )
- itemList.add(olderSectionTitleViewModel)
- olderPlayedStoryList.forEachIndexed { index, promotedStory ->
- val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
- itemList.add(ongoingStoryViewModel)
- }
- }
-
- private fun addRecommendedStoryListSection(suggestedStoryList: List) {
- val showDivider = itemList.isNotEmpty()
- val recommendedSectionTitleViewModel =
- SectionTitleViewModel(
- resourceHandler.getStringInLocale(R.string.recommended_stories),
- showDivider
- )
- itemList.add(recommendedSectionTitleViewModel)
- suggestedStoryList.forEachIndexed { index, suggestedStory ->
- val ongoingStoryViewModel = createOngoingStoryViewModel(suggestedStory, index)
- itemList.add(ongoingStoryViewModel)
- }
- }
-
- private fun getAssumedSuccessfulPromotedActivityList(): LiveData {
- return Transformations.map(promotedStoryListSummaryResultLiveData) {
- when (it) {
- // If there's an error loading the data, assume the default.
- is AsyncResult.Failure, is AsyncResult.Pending -> PromotedActivityList.getDefaultInstance()
- is AsyncResult.Success -> it.value
+ binding =
+ RecentlyPlayedFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false).apply {
+ lifecycleOwner = fragment
+ viewModel = recentlyPlayedViewModel
+ val adapter = createRecyclerViewAdapter()
+ ongoingStoryRecyclerView.layoutManager = createLayoutManager(adapter)
+ ongoingStoryRecyclerView.adapter = adapter
}
- }
+
+ return binding.root
}
private fun createLayoutManager(
- recentStoryCount: Int,
- oldStoryCount: Int,
- suggestedStoryCount: Int
+ adapter: BindableAdapter
): RecyclerView.LayoutManager {
- val sectionTitle0Position = if (recentStoryCount == 0) {
- // If recent story count is 0, that means that section title 0 will not be visible.
- -1
- } else {
- 0
- }
- val sectionTitle1Position = if (oldStoryCount == 0) {
- // If old story count is 0, that means that section title 1 will not be visible.
- -1
- } else if (recentStoryCount == 0) {
- 0
- } else {
- recentStoryCount + 1
- }
- val sectionTitle2Position = when {
- suggestedStoryCount == 0 -> {
- -1 // If suggested story count is 0, that means that section title 1 will not be visible.
- }
- oldStoryCount == 0 && recentStoryCount == 0 -> {
- 0
- }
- oldStoryCount > 0 && recentStoryCount > 0 -> {
- recentStoryCount + oldStoryCount + 2
- }
- else -> {
- recentStoryCount + oldStoryCount + 1
- }
- }
-
val spanCount = activity.resources.getInteger(R.integer.recently_played_span_count)
- promotedStoryListAdapter.setSpanCount(spanCount)
-
val layoutManager = GridLayoutManager(activity.applicationContext, spanCount)
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
- return when (position) {
- sectionTitle0Position, sectionTitle1Position, sectionTitle2Position -> {
- /* number of spaces this item should occupy = */ spanCount
- }
- else -> {
- /* number of spaces this item should occupy = */ 1
- }
+ return if (adapter.getItemViewType(position) == ViewType.VIEW_TYPE_TITLE.ordinal) {
+ spanCount
+ } else {
+ 1
}
}
}
@@ -280,6 +136,31 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
}
}
+ private enum class ViewType {
+ VIEW_TYPE_TITLE,
+ VIEW_TYPE_PROMOTED_STORY
+ }
+
+ private fun createRecyclerViewAdapter(): BindableAdapter {
+ return multiTypeBuilderFactory.create { viewModel ->
+ when (viewModel) {
+ is PromotedStoryViewModel -> ViewType.VIEW_TYPE_PROMOTED_STORY
+ is SectionTitleViewModel -> ViewType.VIEW_TYPE_TITLE
+ else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel")
+ }
+ }.registerViewDataBinder(
+ viewType = ViewType.VIEW_TYPE_TITLE,
+ inflateDataBinding = SectionTitleBinding::inflate,
+ setViewModel = SectionTitleBinding::setViewModel,
+ transformViewModel = { it as SectionTitleViewModel }
+ ).registerViewDataBinder(
+ viewType = ViewType.VIEW_TYPE_PROMOTED_STORY,
+ inflateDataBinding = RecentlyPlayedStoryCardBinding::inflate,
+ setViewModel = RecentlyPlayedStoryCardBinding::setViewModel,
+ transformViewModel = { it as PromotedStoryViewModel }
+ ).build()
+ }
+
private fun playExploration(
topicId: String,
storyId: String,
@@ -293,13 +174,13 @@ class RecentlyPlayedFragmentPresenter @Inject constructor(
// cases, lessons played from this fragment are known to be in progress, and that progress
// can't be resumed here (hence the restart).
explorationDataController.restartExploration(
- profileId = this.profileId, topicId, storyId, explorationId
+ profileId.internalId, topicId, storyId, explorationId
)
} else {
// The only lessons that can't have their progress saved are those that were already
// completed.
explorationDataController.replayExploration(
- profileId = this.profileId, topicId, storyId, explorationId
+ profileId.internalId, topicId, storyId, explorationId
)
}
startPlayingProvider.toLiveData().observe(fragment) { result ->
diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt
new file mode 100644
index 00000000000..7716fdf11f5
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt
@@ -0,0 +1,172 @@
+package org.oppia.android.app.home.recentlyplayed
+
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
+import org.oppia.android.R
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.PromotedActivityList
+import org.oppia.android.app.model.PromotedStory
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.domain.topic.TopicListController
+import org.oppia.android.domain.translation.TranslationController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import org.oppia.android.util.parser.html.StoryHtmlParserEntityType
+import javax.inject.Inject
+
+/** View model for [RecentlyPlayedFragment]. */
+class RecentlyPlayedViewModel private constructor(
+ private val activity: AppCompatActivity,
+ private val topicListController: TopicListController,
+ @StoryHtmlParserEntityType private val entityType: String,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val translationController: TranslationController,
+ private val promotedStoryClickListener: PromotedStoryClickListener,
+ private val profileId: ProfileId,
+) {
+
+ /** Factory of RecentlyPlayedViewModel. */
+ class Factory @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val topicListController: TopicListController,
+ @StoryHtmlParserEntityType private val entityType: String,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val translationController: TranslationController,
+ ) {
+
+ /** Creates an instance of [RecentlyPlayedViewModel]. */
+ fun create(
+ promotedStoryClickListener: PromotedStoryClickListener,
+ profileId: ProfileId
+ ): RecentlyPlayedViewModel {
+ return RecentlyPlayedViewModel(
+ activity,
+ topicListController,
+ entityType,
+ resourceHandler,
+ translationController,
+ promotedStoryClickListener,
+ profileId,
+ )
+ }
+ }
+
+ /**
+ * [LiveData] with the list of recently played items for a ProfileId, organized in sections.
+ */
+ val recentlyPlayedItems: LiveData> by lazy {
+ Transformations.map(promotedActivityListLiveData, ::processPromotedStoryList)
+ }
+
+ private val promotedActivityListLiveData: LiveData by lazy {
+ getAssumedSuccessfulPromotedActivityList()
+ }
+
+ private val promotedStoryListSummaryResultLiveData:
+ LiveData>
+ by lazy {
+ topicListController.getPromotedActivityList(profileId).toLiveData()
+ }
+
+ private fun getAssumedSuccessfulPromotedActivityList(): LiveData {
+ return Transformations.map(promotedStoryListSummaryResultLiveData) {
+ when (it) {
+ // If there's an error loading the data, assume the default.
+ is AsyncResult.Failure, is AsyncResult.Pending -> PromotedActivityList.getDefaultInstance()
+ is AsyncResult.Success -> it.value
+ }
+ }
+ }
+
+ private fun processPromotedStoryList(
+ promotedActivityList: PromotedActivityList
+ ): List {
+ val itemList: MutableList = mutableListOf()
+ if (promotedActivityList.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) {
+ addRecentlyPlayedStoryListSection(
+ promotedActivityList.promotedStoryList.recentlyPlayedStoryList,
+ itemList
+ )
+ }
+
+ if (promotedActivityList.promotedStoryList.olderPlayedStoryList.isNotEmpty()) {
+ addOlderStoryListSection(
+ promotedActivityList.promotedStoryList.olderPlayedStoryList,
+ itemList
+ )
+ }
+
+ if (promotedActivityList.promotedStoryList.suggestedStoryList.isNotEmpty()) {
+ addRecommendedStoryListSection(
+ promotedActivityList.promotedStoryList.suggestedStoryList,
+ itemList
+ )
+ }
+ return itemList
+ }
+
+ private fun addRecentlyPlayedStoryListSection(
+ recentlyPlayedStoryList: MutableList,
+ itemList: MutableList
+ ) {
+ val recentSectionTitleViewModel =
+ SectionTitleViewModel(
+ resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false
+ )
+ itemList.add(recentSectionTitleViewModel)
+ recentlyPlayedStoryList.forEachIndexed { index, promotedStory ->
+ val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
+ itemList.add(ongoingStoryViewModel)
+ }
+ }
+
+ private fun addOlderStoryListSection(
+ olderPlayedStoryList: List,
+ itemList: MutableList
+ ) {
+ val showDivider = itemList.isNotEmpty()
+ val olderSectionTitleViewModel =
+ SectionTitleViewModel(
+ resourceHandler.getStringInLocale(R.string.ongoing_story_last_month),
+ showDivider
+ )
+ itemList.add(olderSectionTitleViewModel)
+ olderPlayedStoryList.forEachIndexed { index, promotedStory ->
+ val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index)
+ itemList.add(ongoingStoryViewModel)
+ }
+ }
+
+ private fun addRecommendedStoryListSection(
+ suggestedStoryList: List,
+ itemList: MutableList
+ ) {
+ val showDivider = itemList.isNotEmpty()
+ val recommendedSectionTitleViewModel =
+ SectionTitleViewModel(
+ resourceHandler.getStringInLocale(R.string.recommended_stories),
+ showDivider
+ )
+ itemList.add(recommendedSectionTitleViewModel)
+ suggestedStoryList.forEachIndexed { index, suggestedStory ->
+ val ongoingStoryViewModel = createOngoingStoryViewModel(suggestedStory, index)
+ itemList.add(ongoingStoryViewModel)
+ }
+ }
+
+ private fun createOngoingStoryViewModel(
+ promotedStory: PromotedStory,
+ index: Int
+ ): RecentlyPlayedItemViewModel {
+ return PromotedStoryViewModel(
+ activity,
+ promotedStory,
+ entityType,
+ promotedStoryClickListener,
+ index,
+ resourceHandler,
+ translationController
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
index 803d5f86e8d..cc1b92301b8 100644
--- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt
@@ -19,6 +19,7 @@ import org.oppia.android.app.model.ExplorationActivityParams
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.ReadingTextSize
import org.oppia.android.app.model.Spotlight
+import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.app.options.OptionsActivity
import org.oppia.android.app.player.stopplaying.ProgressDatabaseFullDialogFragment
import org.oppia.android.app.player.stopplaying.UnsavedExplorationDialogFragment
@@ -26,6 +27,8 @@ import org.oppia.android.app.spotlight.SpotlightFragment
import org.oppia.android.app.spotlight.SpotlightManager
import org.oppia.android.app.spotlight.SpotlightShape
import org.oppia.android.app.spotlight.SpotlightTarget
+import org.oppia.android.app.survey.SurveyWelcomeDialogFragment
+import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG
import org.oppia.android.app.topic.TopicActivity
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.utility.FontScaleConfigurationUtil
@@ -33,6 +36,7 @@ import org.oppia.android.app.viewmodel.ViewModelProvider
import org.oppia.android.databinding.ExplorationActivityBinding
import org.oppia.android.domain.exploration.ExplorationDataController
import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyGatingController
import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
@@ -54,7 +58,8 @@ class ExplorationActivityPresenter @Inject constructor(
private val fontScaleConfigurationUtil: FontScaleConfigurationUtil,
private val translationController: TranslationController,
private val oppiaLogger: OppiaLogger,
- private val resourceHandler: AppLanguageResourceHandler
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val surveyGatingController: SurveyGatingController
) {
private lateinit var explorationToolbar: Toolbar
private lateinit var explorationToolbarTitle: TextView
@@ -279,8 +284,11 @@ class ExplorationActivityPresenter @Inject constructor(
oppiaLogger.e("ExplorationActivity", "Failed to stop exploration", it.error)
is AsyncResult.Success -> {
oppiaLogger.d("ExplorationActivity", "Successfully stopped exploration")
- backPressActivitySelector()
- (activity as ExplorationActivity).finish()
+ if (isCompletion) {
+ maybeShowSurveyDialog(profileId, topicId)
+ } else {
+ backPressActivitySelector()
+ }
}
}
}
@@ -500,4 +508,52 @@ class ExplorationActivityPresenter @Inject constructor(
}
}
}
+
+ private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) {
+ surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData()
+ .observe(
+ activity,
+ { gatingResult ->
+ when (gatingResult) {
+ is AsyncResult.Pending -> {
+ oppiaLogger.d("ExplorationActivity", "A gating decision is pending")
+ }
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "ExplorationActivity",
+ "Failed to retrieve gating decision",
+ gatingResult.error
+ )
+ backPressActivitySelector()
+ }
+ is AsyncResult.Success -> {
+ if (gatingResult.value) {
+ val dialogFragment =
+ SurveyWelcomeDialogFragment.newInstance(
+ profileId,
+ topicId,
+ explorationId,
+ SURVEY_QUESTIONS
+ )
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction
+ .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG)
+ .addToBackStack(null)
+ .commit()
+ } else {
+ backPressActivitySelector()
+ }
+ }
+ }
+ }
+ )
+ }
+
+ companion object {
+ private val SURVEY_QUESTIONS = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ }
}
diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
index 2ca6d7bb061..224d938e683 100755
--- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt
@@ -25,6 +25,7 @@ import org.oppia.android.app.model.EphemeralState
import org.oppia.android.app.model.HelpIndex
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.State
+import org.oppia.android.app.model.SurveyQuestionName
import org.oppia.android.app.model.UserAnswer
import org.oppia.android.app.player.audio.AudioButtonListener
import org.oppia.android.app.player.audio.AudioFragment
@@ -34,6 +35,8 @@ import org.oppia.android.app.player.state.ConfettiConfig.MEDIUM_CONFETTI_BURST
import org.oppia.android.app.player.state.ConfettiConfig.MINI_CONFETTI_BURST
import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener
import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener
+import org.oppia.android.app.survey.SurveyWelcomeDialogFragment
+import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG
import org.oppia.android.app.topic.conceptcard.ConceptCardFragment
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.utility.SplitScreenManager
@@ -41,6 +44,7 @@ import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory
import org.oppia.android.databinding.StateFragmentBinding
import org.oppia.android.domain.exploration.ExplorationProgressController
import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyGatingController
import org.oppia.android.domain.topic.StoryProgressController
import org.oppia.android.util.accessibility.AccessibilityService
import org.oppia.android.util.data.AsyncResult
@@ -74,7 +78,8 @@ class StateFragmentPresenter @Inject constructor(
private val oppiaClock: OppiaClock,
private val viewModel: StateViewModel,
private val accessibilityService: AccessibilityService,
- private val resourceHandler: AppLanguageResourceHandler
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val surveyGatingController: SurveyGatingController
) {
private val routeToHintsAndSolutionListener = activity as RouteToHintsAndSolutionListener
@@ -182,8 +187,7 @@ class StateFragmentPresenter @Inject constructor(
fun onReturnToTopicButtonClicked() {
hideKeyboard()
markExplorationCompleted()
- (activity as StopStatePlayingSessionWithSavedProgressListener)
- .deleteCurrentProgressAndStopSession(isCompletion = true)
+ maybeShowSurveyDialog(profileId, topicId)
}
private fun showOrHideAudioByState(state: State) {
@@ -522,6 +526,47 @@ class StateFragmentPresenter @Inject constructor(
}
}
+ private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) {
+ surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData()
+ .observe(
+ activity,
+ { gatingResult ->
+ when (gatingResult) {
+ is AsyncResult.Pending -> {
+ oppiaLogger.d("StateFragment", "A gating decision is pending")
+ }
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "StateFragment",
+ "Failed to retrieve gating decision",
+ gatingResult.error
+ )
+ (activity as StopStatePlayingSessionWithSavedProgressListener)
+ .deleteCurrentProgressAndStopSession(isCompletion = true)
+ }
+ is AsyncResult.Success -> {
+ if (gatingResult.value) {
+ val dialogFragment =
+ SurveyWelcomeDialogFragment.newInstance(
+ profileId,
+ topicId,
+ explorationId,
+ SURVEY_QUESTIONS
+ )
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction
+ .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG)
+ .commitNow()
+ } else {
+ (activity as StopStatePlayingSessionWithSavedProgressListener)
+ .deleteCurrentProgressAndStopSession(isCompletion = true)
+ }
+ }
+ }
+ }
+ )
+ }
+
/**
* An [Interpolator] when performs a reversed, then regular bounce interpolation using
* [BounceInterpolator].
@@ -543,4 +588,12 @@ class StateFragmentPresenter @Inject constructor(
} else bounceInterpolator.getInterpolation(input * 2f - 1f)
}
}
+
+ companion object {
+ private val SURVEY_QUESTIONS = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ }
}
diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt
index 90dbfa317d4..8f28851bcf2 100644
--- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt
+++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt
@@ -1,7 +1,6 @@
package org.oppia.android.app.profileprogress
import android.content.Intent
-import android.provider.MediaStore
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
@@ -37,9 +36,9 @@ class ProfileProgressActivityPresenter @Inject constructor(
R.id.profile_progress_activity_toolbar
) as Toolbar
activity.setSupportActionBar(toolbar)
- activity.supportActionBar!!.setTitle(R.string.profile)
- activity.supportActionBar!!.setDisplayShowHomeEnabled(true)
- activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+ (activity.supportActionBar ?: return).setTitle(R.string.profile)
+ (activity.supportActionBar ?: return).setDisplayShowHomeEnabled(true)
+ (activity.supportActionBar ?: return).setDisplayHomeAsUpEnabled(true)
toolbar.setNavigationOnClickListener {
activity.finish()
}
@@ -52,7 +51,7 @@ class ProfileProgressActivityPresenter @Inject constructor(
}
fun openGalleryIntent() {
- val galleryIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+ val galleryIntent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "image/*" }
activity.startActivityForResult(galleryIntent, GALLERY_INTENT_RESULT_CODE)
}
diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt
index cb57334b06d..acc6efcec4a 100644
--- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt
+++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt
@@ -12,6 +12,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel
import org.oppia.android.app.model.WrittenTranslationContext
import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel
import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
import org.oppia.android.util.parser.html.HtmlParser
/**
@@ -167,4 +168,42 @@ interface ViewBindingShim {
entityId: String,
writtenTranslationContext: WrittenTranslationContext
)
+
+ /**
+ * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and
+ * returns the binding's view.
+ */
+ fun provideMultipleChoiceItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View
+
+ /**
+ * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and
+ * returns the binding's view model.
+ */
+ fun provideMultipleChoiceOptionViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ )
+
+ /**
+ * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and
+ * returns the binding's view.
+ */
+ fun provideNpsItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View
+
+ /**
+ * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and
+ * returns the binding's view model.
+ */
+ fun provideNpsItemsViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ )
}
diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt
index cd577465e07..69b49ac4eea 100644
--- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt
+++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt
@@ -14,6 +14,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel
import org.oppia.android.app.model.WrittenTranslationContext
import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel
import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.databinding.ComingSoonTopicViewBinding
import org.oppia.android.databinding.DragDropInteractionItemsBinding
@@ -21,6 +22,8 @@ import org.oppia.android.databinding.DragDropSingleItemBinding
import org.oppia.android.databinding.ItemSelectionInteractionItemsBinding
import org.oppia.android.databinding.MultipleChoiceInteractionItemsBinding
import org.oppia.android.databinding.PromotedStoryCardBinding
+import org.oppia.android.databinding.SurveyMultipleChoiceItemBinding
+import org.oppia.android.databinding.SurveyNpsItemBinding
import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.parser.html.HtmlParser
import javax.inject.Inject
@@ -148,6 +151,50 @@ class ViewBindingShimImpl @Inject constructor(
binding.viewModel = viewModel
}
+ override fun provideMultipleChoiceItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View {
+ return SurveyMultipleChoiceItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ ).root
+ }
+
+ override fun provideMultipleChoiceOptionViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ ) {
+ val binding =
+ DataBindingUtil.findBinding(view)!!
+ binding.optionContent = viewModel.optionContent
+ binding.viewModel = viewModel
+ }
+
+ override fun provideNpsItemsInflatedView(
+ inflater: LayoutInflater,
+ parent: ViewGroup,
+ attachToParent: Boolean
+ ): View {
+ return SurveyNpsItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ ).root
+ }
+
+ override fun provideNpsItemsViewModel(
+ view: View,
+ viewModel: MultipleChoiceOptionContentViewModel
+ ) {
+ val binding =
+ DataBindingUtil.findBinding(view)!!
+ binding.scoreContent = viewModel.optionContent
+ binding.viewModel = viewModel
+ }
+
override fun provideDragDropSortInteractionInflatedView(
inflater: LayoutInflater,
parent: ViewGroup,
diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
new file mode 100644
index 00000000000..f111bafbec6
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt
@@ -0,0 +1,77 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableDialogFragment
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.util.extensions.getProto
+import org.oppia.android.util.extensions.putProto
+import javax.inject.Inject
+
+/** Fragment that displays a dialog for survey exit confirmation. */
+class ExitSurveyConfirmationDialogFragment : InjectableDialogFragment() {
+ @Inject
+ lateinit var exitSurveyConfirmationDialogFragmentPresenter:
+ ExitSurveyConfirmationDialogFragmentPresenter
+
+ companion object {
+ internal const val PROFILE_ID_KEY = "ExitSurveyConfirmationDialogFragment.profile_id"
+
+ /**
+ * Creates a new instance of a DialogFragment to display an exit confirmation in a survey.
+ *
+ * @param profileId the ID of the profile viewing the survey
+ * @return [ExitSurveyConfirmationDialogFragment]: DialogFragment
+ */
+ fun newInstance(
+ profileId: ProfileId
+ ): ExitSurveyConfirmationDialogFragment {
+ return ExitSurveyConfirmationDialogFragment().apply {
+ arguments = Bundle().apply {
+ putProto(PROFILE_ID_KEY, profileId)
+ }
+ }
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.ExitSurveyConfirmationDialogStyle)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val args =
+ checkNotNull(
+ arguments
+ ) { "Expected arguments to be passed to ExitSurveyConfirmationDialogFragment" }
+
+ val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance())
+
+ dialog?.setCanceledOnTouchOutside(false)
+ dialog?.setCancelable(false)
+
+ return exitSurveyConfirmationDialogFragmentPresenter.handleCreateView(
+ inflater,
+ container
+ )
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setWindowAnimations(R.style.ExitSurveyConfirmationDialogStyle)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
new file mode 100644
index 00000000000..3d5f3dd66a5
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt
@@ -0,0 +1,75 @@
+package org.oppia.android.app.survey
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.oppia.android.app.fragment.FragmentScope
+import org.oppia.android.databinding.SurveyExitConfirmationDialogBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+const val TAG_EXIT_SURVEY_CONFIRMATION_DIALOG = "EXIT_SURVEY_CONFIRMATION_DIALOG"
+
+/** Presenter for [ExitSurveyConfirmationDialogFragment], sets up bindings from ViewModel. */
+@FragmentScope
+class ExitSurveyConfirmationDialogFragmentPresenter @Inject constructor(
+ private val fragment: Fragment,
+ private val activity: AppCompatActivity,
+ private val surveyController: SurveyController,
+ private val oppiaLogger: OppiaLogger
+) {
+
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?
+ ): View {
+ val binding =
+ SurveyExitConfirmationDialogBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ binding.lifecycleOwner = fragment
+
+ binding.continueSurveyButton.setOnClickListener {
+ fragment.parentFragmentManager.beginTransaction()
+ .remove(fragment)
+ .commitNow()
+ }
+
+ binding.exitSurveyButton.setOnClickListener {
+ endSurveyWithCallback { closeSurveyDialogAndActivity() }
+ }
+
+ return binding.root
+ }
+
+ private fun closeSurveyDialogAndActivity() {
+ activity.finish()
+ fragment.parentFragmentManager.beginTransaction()
+ .remove(fragment)
+ .commitNow()
+ }
+
+ private fun endSurveyWithCallback(callback: () -> Unit) {
+ surveyController.stopSurveySession(surveyCompleted = false).toLiveData().observe(
+ activity,
+ {
+ when (it) {
+ is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session")
+ is AsyncResult.Failure -> {
+ oppiaLogger.d("SurveyActivity", "Failed to stop the survey session")
+ activity.finish() // Can't recover from the session failing to stop.
+ }
+ is AsyncResult.Success -> {
+ oppiaLogger.d("SurveyActivity", "Stopped the survey session")
+ callback()
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt
new file mode 100644
index 00000000000..d0d1c16b079
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt
@@ -0,0 +1,29 @@
+package org.oppia.android.app.survey
+
+import org.oppia.android.app.model.SurveySelectedAnswer
+
+/** A handler for receiving any change in answer availability to update the 'next' button. */
+interface SelectedAnswerAvailabilityReceiver {
+ /** Called when the input answer availability changes. */
+ fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean)
+}
+
+/** A callback that will be called when a user submits an answer. */
+interface SelectedAnswerHandler {
+ /** Return the current selected answer that is ready for submission. */
+ fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer)
+
+ /** Return the current text answer that is ready for submission. */
+ fun getFreeFormAnswer(answer: SurveySelectedAnswer)
+}
+
+/** A handler for restoring the previous saved answer for a question on back/forward navigation. */
+interface PreviousAnswerHandler {
+ /** Called when an ephemeral question is loaded to retrieve the previously saved answer. */
+ fun getPreviousAnswer(): SurveySelectedAnswer? {
+ return null
+ }
+
+ /** Called after a previously saved answer is retrieved to update the UI. */
+ fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer)
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
new file mode 100644
index 00000000000..12d5b7220ee
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt
@@ -0,0 +1,77 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import org.oppia.android.app.activity.ActivityComponentImpl
+import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.ScreenName
+import org.oppia.android.app.model.SurveyActivityParams
+import org.oppia.android.util.extensions.getProtoExtra
+import org.oppia.android.util.extensions.putProtoExtra
+import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
+import javax.inject.Inject
+
+/** The activity for showing a survey. */
+class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() {
+ @Inject
+ lateinit var surveyActivityPresenter: SurveyActivityPresenter
+
+ private lateinit var profileId: ProfileId
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ (activityComponent as ActivityComponentImpl).inject(this)
+
+ val params = intent.extractParams()
+ this.profileId = params.profileId ?: ProfileId.newBuilder().setInternalId(-1).build()
+
+ surveyActivityPresenter.handleOnCreate(
+ profileId,
+ params.topicId,
+ params.explorationId
+ )
+ }
+
+ override fun onBackPressed() {
+ val dialogFragment = ExitSurveyConfirmationDialogFragment.newInstance(profileId)
+ dialogFragment.showNow(supportFragmentManager, TAG_EXIT_SURVEY_CONFIRMATION_DIALOG)
+ }
+
+ companion object {
+ private const val PARAMS_KEY = "SurveyActivity.params"
+
+ /**
+ * A convenience function for creating a new [SurveyActivity] intent by prefilling common
+ * params needed by the activity.
+ */
+ fun createSurveyActivityIntent(
+ context: Context,
+ profileId: ProfileId,
+ topicId: String,
+ explorationId: String
+ ): Intent {
+ val params = SurveyActivityParams.newBuilder().apply {
+ this.profileId = profileId
+ this.topicId = topicId
+ this.explorationId = explorationId
+ }.build()
+ return createSurveyActivityIntent(context, params)
+ }
+
+ /** Returns a new [Intent] open a [SurveyActivity] with the specified [params]. */
+ fun createSurveyActivityIntent(
+ context: Context,
+ params: SurveyActivityParams
+ ): Intent {
+ return Intent(context, SurveyActivity::class.java).apply {
+ putProtoExtra(PARAMS_KEY, params)
+ decorateWithScreenName(ScreenName.SURVEY_ACTIVITY)
+ }
+ }
+
+ private fun Intent.extractParams() =
+ getProtoExtra(PARAMS_KEY, SurveyActivityParams.getDefaultInstance())
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
new file mode 100644
index 00000000000..8020dfe8796
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt
@@ -0,0 +1,53 @@
+package org.oppia.android.app.survey
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import org.oppia.android.R
+import org.oppia.android.app.activity.ActivityScope
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.databinding.SurveyActivityBinding
+import javax.inject.Inject
+
+private const val TAG_SURVEY_FRAGMENT = "TAG_SURVEY_FRAGMENT"
+
+const val PROFILE_ID_ARGUMENT_KEY = "profile_id"
+const val TOPIC_ID_ARGUMENT_KEY = "topic_id"
+const val EXPLORATION_ID_ARGUMENT_KEY = "exploration_id"
+
+/** The Presenter for [SurveyActivity]. */
+@ActivityScope
+class SurveyActivityPresenter @Inject constructor(private val activity: AppCompatActivity) {
+ private lateinit var binding: SurveyActivityBinding
+
+ fun handleOnCreate(
+ profileId: ProfileId,
+ topicId: String,
+ explorationId: String
+ ) {
+ binding = DataBindingUtil.setContentView(activity, R.layout.survey_activity)
+ binding.apply {
+ lifecycleOwner = activity
+ }
+
+ if (getSurveyFragment() == null) {
+ val surveyFragment = SurveyFragment()
+ val args = Bundle()
+ args.putInt(PROFILE_ID_ARGUMENT_KEY, profileId.internalId)
+ args.putString(TOPIC_ID_ARGUMENT_KEY, topicId)
+ args.putString(EXPLORATION_ID_ARGUMENT_KEY, explorationId)
+
+ surveyFragment.arguments = args
+ activity.supportFragmentManager.beginTransaction().add(
+ R.id.survey_fragment_placeholder,
+ surveyFragment, TAG_SURVEY_FRAGMENT
+ ).commitNow()
+ }
+ }
+
+ private fun getSurveyFragment(): SurveyFragment? {
+ return activity.supportFragmentManager.findFragmentByTag(
+ TAG_SURVEY_FRAGMENT
+ ) as? SurveyFragment
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
new file mode 100644
index 00000000000..c6b012fd855
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt
@@ -0,0 +1,79 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableFragment
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.util.extensions.getStringFromBundle
+import javax.inject.Inject
+
+/** Fragment that represents the current state of a survey. */
+class SurveyFragment :
+ InjectableFragment(),
+ SelectedAnswerAvailabilityReceiver,
+ SelectedAnswerHandler {
+
+ companion object {
+ /**
+ * Creates a new instance of a SurveyFragment.
+ *
+ * @param internalProfileId used by SurveyFragment to record the survey action taken by user
+ * @param topicId used by SurveyFragment for logging purposes
+ * @return a new instance of [SurveyFragment]
+ */
+ fun newInstance(
+ internalProfileId: Int,
+ topicId: String
+ ): SurveyFragment {
+ val surveyFragment = SurveyFragment()
+ val args = Bundle()
+ args.putInt(PROFILE_ID_ARGUMENT_KEY, internalProfileId)
+ args.putString(TOPIC_ID_ARGUMENT_KEY, topicId)
+ surveyFragment.arguments = args
+ return surveyFragment
+ }
+ }
+
+ @Inject
+ lateinit var surveyFragmentPresenter: SurveyFragmentPresenter
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val internalProfileId = arguments!!.getInt(PROFILE_ID_ARGUMENT_KEY, -1)
+ val topicId = arguments!!.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)!!
+ val explorationId = arguments!!.getStringFromBundle(EXPLORATION_ID_ARGUMENT_KEY)!!
+
+ return surveyFragmentPresenter.handleCreateView(
+ inflater,
+ container,
+ internalProfileId,
+ explorationId,
+ topicId,
+ this
+ )
+ }
+
+ override fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean) {
+ surveyFragmentPresenter.updateNextButton(inputAnswerAvailable)
+ }
+
+ override fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer) {
+ surveyFragmentPresenter.getPendingAnswer(selectedAnswer)
+ }
+
+ override fun getFreeFormAnswer(answer: SurveySelectedAnswer) {
+ surveyFragmentPresenter.submitFreeFormAnswer(answer)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
new file mode 100644
index 00000000000..2d2b1ccca0f
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt
@@ -0,0 +1,355 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.LiveData
+import com.google.android.flexbox.FlexDirection
+import com.google.android.flexbox.FlexboxLayoutManager
+import com.google.android.flexbox.JustifyContent
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.recyclerview.BindableAdapter
+import org.oppia.android.app.survey.surveyitemviewmodel.FreeFormItemsViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.MarketFitItemsViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.NpsItemsViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel
+import org.oppia.android.app.survey.surveyitemviewmodel.UserTypeItemsViewModel
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.databinding.SurveyFragmentBinding
+import org.oppia.android.databinding.SurveyFreeFormLayoutBinding
+import org.oppia.android.databinding.SurveyMarketFitQuestionLayoutBinding
+import org.oppia.android.databinding.SurveyNpsScoreLayoutBinding
+import org.oppia.android.databinding.SurveyUserTypeQuestionLayoutBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
+import org.oppia.android.domain.survey.SurveyProgressController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+/** The presenter for [SurveyFragment]. */
+class SurveyFragmentPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val fragment: Fragment,
+ private val oppiaLogger: OppiaLogger,
+ private val surveyProgressController: SurveyProgressController,
+ private val surveyViewModel: SurveyViewModel,
+ private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val analyticsController: AnalyticsController
+) {
+ private val ephemeralQuestionLiveData: LiveData> by lazy {
+ surveyProgressController.getCurrentQuestion().toLiveData()
+ }
+
+ private lateinit var profileId: ProfileId
+ private lateinit var binding: SurveyFragmentBinding
+ private lateinit var surveyToolbar: Toolbar
+ private lateinit var answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver
+ private lateinit var answerHandler: SelectedAnswerHandler
+ private lateinit var questionSelectedAnswer: SurveySelectedAnswer
+ private var isCurrentQuestionTerminal: Boolean = false
+
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ internalProfileId: Int,
+ explorationId: String,
+ topicId: String,
+ fragment: SurveyFragment
+ ): View? {
+ profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build()
+ this.answerAvailabilityReceiver = fragment
+ this.answerHandler = fragment
+
+ binding = SurveyFragmentBinding.inflate(
+ inflater,
+ container,
+ /* attachToRoot= */ false
+ )
+ binding.apply {
+ lifecycleOwner = fragment
+ viewModel = surveyViewModel
+ }
+
+ surveyToolbar = binding.surveyToolbar
+ activity.setSupportActionBar(surveyToolbar)
+ surveyToolbar.setNavigationOnClickListener {
+ val dialogFragment = ExitSurveyConfirmationDialogFragment.newInstance(profileId)
+ dialogFragment.showNow(fragment.childFragmentManager, TAG_EXIT_SURVEY_CONFIRMATION_DIALOG)
+ }
+
+ binding.surveyAnswersRecyclerView.apply {
+ adapter = createRecyclerViewAdapter()
+ }
+
+ binding.surveyNextButton.setOnClickListener {
+ if (::questionSelectedAnswer.isInitialized) {
+ surveyProgressController.submitAnswer(questionSelectedAnswer)
+ }
+ }
+
+ binding.surveyPreviousButton.setOnClickListener {
+ surveyProgressController.moveToPreviousQuestion()
+ }
+
+ logBeginSurveyEvent(explorationId, topicId, profileId)
+
+ subscribeToCurrentQuestion()
+
+ return binding.root
+ }
+
+ private fun createRecyclerViewAdapter(): BindableAdapter {
+ return multiTypeBuilderFactory
+ .create { viewModel ->
+ when (viewModel) {
+ is MarketFitItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS
+ }
+ is UserTypeItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS
+ }
+ is NpsItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS
+ }
+ is FreeFormItemsViewModel -> {
+ SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER
+ }
+ else -> {
+ throw IllegalStateException("Invalid ViewType")
+ }
+ }
+ }
+ .registerViewDataBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS,
+ inflateDataBinding = SurveyUserTypeQuestionLayoutBinding::inflate,
+ setViewModel = SurveyUserTypeQuestionLayoutBinding::setViewModel,
+ transformViewModel = { it as UserTypeItemsViewModel }
+ )
+ .registerViewDataBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS,
+ inflateDataBinding = SurveyMarketFitQuestionLayoutBinding::inflate,
+ setViewModel = SurveyMarketFitQuestionLayoutBinding::setViewModel,
+ transformViewModel = { it as MarketFitItemsViewModel }
+ )
+ .registerViewBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS,
+ inflateView = { parent ->
+ SurveyNpsScoreLayoutBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ /* attachToParent= */ false
+ ).root
+ },
+ bindView = { view, viewModel ->
+ val binding = DataBindingUtil.findBinding(view)!!
+ val npsViewModel = viewModel as NpsItemsViewModel
+ binding.viewModel = npsViewModel
+
+ val flexLayoutManager = FlexboxLayoutManager(activity)
+ flexLayoutManager.flexDirection = FlexDirection.ROW
+ flexLayoutManager.justifyContent = JustifyContent.CENTER
+
+ binding.surveyNpsButtonsContainer.layoutManager = flexLayoutManager
+ }
+ )
+ .registerViewDataBinder(
+ viewType = SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER,
+ inflateDataBinding = SurveyFreeFormLayoutBinding::inflate,
+ setViewModel = SurveyFreeFormLayoutBinding::setViewModel,
+ transformViewModel = { it as FreeFormItemsViewModel }
+ )
+ .build()
+ }
+
+ private fun subscribeToCurrentQuestion() {
+ ephemeralQuestionLiveData.observe(
+ fragment,
+ {
+ processEphemeralQuestionResult(it)
+ }
+ )
+ }
+
+ private fun processEphemeralQuestionResult(result: AsyncResult) {
+ when (result) {
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "SurveyFragment", "Failed to retrieve ephemeral question", result.error
+ )
+ }
+ is AsyncResult.Pending -> {} // Display nothing until a valid result is available.
+ is AsyncResult.Success -> processEphemeralQuestion(result.value)
+ }
+ }
+
+ private fun processEphemeralQuestion(ephemeralQuestion: EphemeralSurveyQuestion) {
+ val questionName = ephemeralQuestion.question.questionName
+ surveyViewModel.itemList.clear()
+ when (questionName) {
+ SurveyQuestionName.USER_TYPE -> surveyViewModel.itemList.add(
+ UserTypeItemsViewModel(
+ resourceHandler,
+ answerAvailabilityReceiver,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.MARKET_FIT -> surveyViewModel.itemList.add(
+ MarketFitItemsViewModel(
+ resourceHandler,
+ answerAvailabilityReceiver,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.NPS -> surveyViewModel.itemList.add(
+ NpsItemsViewModel(
+ answerAvailabilityReceiver,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.PROMOTER_FEEDBACK -> surveyViewModel.itemList.add(
+ FreeFormItemsViewModel(
+ answerAvailabilityReceiver,
+ questionName,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.PASSIVE_FEEDBACK -> surveyViewModel.itemList.add(
+ FreeFormItemsViewModel(
+ answerAvailabilityReceiver,
+ questionName,
+ answerHandler
+ )
+ )
+ SurveyQuestionName.DETRACTOR_FEEDBACK -> surveyViewModel.itemList.add(
+ FreeFormItemsViewModel(
+ answerAvailabilityReceiver,
+ questionName,
+ answerHandler
+ )
+ )
+ else -> {}
+ }
+
+ this.isCurrentQuestionTerminal = ephemeralQuestion.terminalQuestion
+ updateProgress(ephemeralQuestion.currentQuestionIndex, ephemeralQuestion.totalQuestionCount)
+ updateQuestionText(questionName)
+
+ if (ephemeralQuestion.selectedAnswer != SurveySelectedAnswer.getDefaultInstance()) {
+ surveyViewModel.retrievePreviousAnswer(
+ ephemeralQuestion.selectedAnswer,
+ ::getPreviousAnswerHandler
+ )
+ }
+ }
+
+ private fun getPreviousAnswerHandler(
+ itemList: List
+ ): PreviousAnswerHandler? {
+ return itemList.findLast { it is PreviousAnswerHandler } as? PreviousAnswerHandler
+ }
+
+ private fun updateProgress(currentQuestionIndex: Int, questionCount: Int) {
+ surveyViewModel.updateQuestionProgress(
+ progressPercentage = (((currentQuestionIndex + 1) / questionCount.toDouble()) * 100).toInt()
+ )
+ toggleNavigationButtonVisibility(currentQuestionIndex, questionCount)
+ }
+
+ private fun updateQuestionText(questionName: SurveyQuestionName) {
+ surveyViewModel.updateQuestionText(questionName)
+ }
+
+ /**
+ * Updates whether the 'next' button should be active based on whether an answer to the current
+ * question has been provided.
+ */
+ fun updateNextButton(inputAnswerAvailable: Boolean) {
+ surveyViewModel.setCanMoveToNextQuestion(inputAnswerAvailable)
+ }
+
+ /** Retrieves the answer that was selected by the user for a question. */
+ fun getPendingAnswer(answer: SurveySelectedAnswer) {
+ this.questionSelectedAnswer = answer
+ }
+
+ /**
+ * Retrieves and submits the free text answer that was provided by the user, then navigates to the
+ * final screen.
+ */
+ fun submitFreeFormAnswer(answer: SurveySelectedAnswer) {
+ hideKeyboard()
+ surveyProgressController.submitAnswer(answer).toLiveData().observe(
+ fragment,
+ { result ->
+ when (result) {
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "SurveyFragment", "Failed to submit free form answer", result.error
+ )
+ }
+ is AsyncResult.Pending -> {} // Do nothing until a valid result is available.
+ is AsyncResult.Success -> {
+ val dialogFragment = SurveyOutroDialogFragment.newInstance()
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction
+ .add(dialogFragment, TAG_SURVEY_OUTRO_DIALOG)
+ .commit()
+ activity.supportFragmentManager.executePendingTransactions()
+ }
+ }
+ }
+ )
+ }
+
+ private fun toggleNavigationButtonVisibility(questionIndex: Int, questionCount: Int) {
+ when (questionIndex) {
+ 0 -> {
+ binding.surveyNextButton.visibility = View.VISIBLE
+ binding.surveyPreviousButton.visibility = View.GONE
+ }
+ (questionCount - 1) -> {
+ binding.surveyNextButton.visibility = View.GONE
+ binding.surveyPreviousButton.visibility = View.VISIBLE
+ }
+ else -> {
+ binding.surveyNextButton.visibility = View.VISIBLE
+ binding.surveyPreviousButton.visibility = View.VISIBLE
+ }
+ }
+ }
+
+ private fun hideKeyboard() {
+ val inputManager: InputMethodManager =
+ activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ inputManager.hideSoftInputFromWindow(
+ fragment.view!!.windowToken,
+ InputMethodManager.SHOW_FORCED
+ )
+ }
+
+ private fun logBeginSurveyEvent(
+ explorationId: String,
+ topicId: String,
+ profileId: ProfileId
+ ) {
+ analyticsController.logImportantEvent(
+ oppiaLogger.createBeginSurveyContext(
+ explorationId,
+ topicId
+ ),
+ profileId = profileId
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt
new file mode 100644
index 00000000000..9823860f2a6
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt
@@ -0,0 +1,79 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import androidx.databinding.ObservableList
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.recyclerview.widget.RecyclerView
+import org.oppia.android.app.recyclerview.BindableAdapter
+import org.oppia.android.app.shim.ViewBindingShim
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
+import org.oppia.android.app.view.ViewComponentFactory
+import org.oppia.android.app.view.ViewComponentImpl
+import javax.inject.Inject
+
+/**
+ * A custom [RecyclerView] for displaying a variable list of items that may be selected by a user as
+ * part of the multiple choice option selection.
+ */
+class SurveyMultipleChoiceOptionView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : RecyclerView(context, attrs, defStyleAttr) {
+ @Inject
+ lateinit var bindingInterface: ViewBindingShim
+
+ @Inject
+ lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory
+ private lateinit var dataList: ObservableList
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory
+ val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
+ viewComponent.inject(this)
+ maybeInitializeAdapter()
+ }
+
+ /**
+ * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list.
+ *
+ * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter
+ * since this one takes into account initialization order with other binding properties.
+ */
+ fun setSelectionData(dataList: ObservableList) {
+ this.dataList = dataList
+ maybeInitializeAdapter()
+ }
+
+ private fun maybeInitializeAdapter() {
+ if (::singleTypeBuilderFactory.isInitialized &&
+ ::dataList.isInitialized
+ ) {
+ adapter = createAdapter().also { it.setData(dataList) }
+ }
+ }
+
+ private fun createAdapter(): BindableAdapter {
+ return singleTypeBuilderFactory.create()
+ .registerViewBinder(
+ inflateView = { parent ->
+ bindingInterface.provideMultipleChoiceItemsInflatedView(
+ LayoutInflater.from(parent.context),
+ parent,
+ /* attachToParent= */ false
+ )
+ },
+ bindView = { view, viewModel ->
+ bindingInterface.provideMultipleChoiceOptionViewModel(
+ view,
+ viewModel
+ )
+ }
+ )
+ .build()
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt
new file mode 100644
index 00000000000..85625f9a071
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt
@@ -0,0 +1,79 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import androidx.databinding.ObservableList
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.recyclerview.widget.RecyclerView
+import org.oppia.android.app.recyclerview.BindableAdapter
+import org.oppia.android.app.shim.ViewBindingShim
+import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel
+import org.oppia.android.app.view.ViewComponentFactory
+import org.oppia.android.app.view.ViewComponentImpl
+import javax.inject.Inject
+
+/**
+ * A custom [RecyclerView] for displaying a list of radio buttons that may be selected by a user as
+ * a score for the nps score survey question.
+ */
+class SurveyNpsItemOptionView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : RecyclerView(context, attrs, defStyleAttr) {
+ @Inject
+ lateinit var bindingInterface: ViewBindingShim
+
+ @Inject
+ lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory
+ private lateinit var dataList: ObservableList
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory
+ val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl
+ viewComponent.inject(this)
+ maybeInitializeAdapter()
+ }
+
+ /**
+ * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list.
+ *
+ * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter
+ * since this one takes into account initialization order with other binding properties.
+ */
+ fun setSelectionData(dataList: ObservableList) {
+ this.dataList = dataList
+ maybeInitializeAdapter()
+ }
+
+ private fun maybeInitializeAdapter() {
+ if (::singleTypeBuilderFactory.isInitialized &&
+ ::dataList.isInitialized
+ ) {
+ adapter = createAdapter().also { it.setData(dataList) }
+ }
+ }
+
+ private fun createAdapter(): BindableAdapter {
+ return singleTypeBuilderFactory.create()
+ .registerViewBinder(
+ inflateView = { parent ->
+ bindingInterface.provideNpsItemsInflatedView(
+ LayoutInflater.from(parent.context),
+ parent,
+ /* attachToParent= */ false
+ )
+ },
+ bindView = { view, viewModel ->
+ bindingInterface.provideNpsItemsViewModel(
+ view,
+ viewModel
+ )
+ }
+ )
+ .build()
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt
new file mode 100644
index 00000000000..b988415c547
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt
@@ -0,0 +1,54 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableDialogFragment
+import javax.inject.Inject
+
+/** Fragment that displays a fullscreen dialog for survey on-boarding. */
+class SurveyOutroDialogFragment : InjectableDialogFragment() {
+ @Inject
+ lateinit var surveyOutroDialogFragmentPresenter: SurveyOutroDialogFragmentPresenter
+
+ companion object {
+ /**
+ * Creates a new instance of a DialogFragment to display the survey thank you message.
+ *
+ * @return [SurveyOutroDialogFragment]: DialogFragment
+ */
+ fun newInstance(): SurveyOutroDialogFragment {
+ return SurveyOutroDialogFragment()
+ }
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return surveyOutroDialogFragmentPresenter.handleCreateView(
+ inflater,
+ container
+ )
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
new file mode 100644
index 00000000000..8399bb9a0e5
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt
@@ -0,0 +1,74 @@
+package org.oppia.android.app.survey
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentScope
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.databinding.SurveyOutroDialogFragmentBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+const val TAG_SURVEY_OUTRO_DIALOG = "SURVEY_OUTRO_DIALOG"
+
+/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */
+@FragmentScope
+class SurveyOutroDialogFragmentPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val fragment: Fragment,
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val surveyController: SurveyController,
+ private val oppiaLogger: OppiaLogger
+) {
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ ): View {
+ val binding =
+ SurveyOutroDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ binding.lifecycleOwner = fragment
+
+ val appName = resourceHandler.getStringInLocale(R.string.app_name)
+ binding.surveyOnboardingText.text = resourceHandler.getStringInLocaleWithWrapping(
+ R.string.survey_thank_you_message_text, appName
+ )
+
+ binding.finishSurveyButton.setOnClickListener {
+ endSurveyWithCallback { closeSurveyDialogAndActivity() }
+ }
+
+ return binding.root
+ }
+
+ private fun closeSurveyDialogAndActivity() {
+ activity.finish()
+ activity.supportFragmentManager.beginTransaction().remove(fragment).commitNow()
+ }
+
+ private fun endSurveyWithCallback(callback: () -> Unit) {
+ surveyController.stopSurveySession(surveyCompleted = true).toLiveData().observe(
+ activity,
+ {
+ when (it) {
+ is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session")
+ is AsyncResult.Failure -> {
+ oppiaLogger.d("SurveyActivity", "Failed to stop the survey session")
+ activity.finish() // Can't recover from the session failing to stop.
+ }
+ is AsyncResult.Success -> {
+ oppiaLogger.d("SurveyActivity", "Stopped the survey session")
+ callback()
+ }
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt
new file mode 100644
index 00000000000..0979177affe
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt
@@ -0,0 +1,97 @@
+package org.oppia.android.app.survey
+
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.R
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.app.viewmodel.ObservableArrayList
+import org.oppia.android.app.viewmodel.ObservableViewModel
+import javax.inject.Inject
+
+class SurveyViewModel @Inject constructor(
+ private val resourceHandler: AppLanguageResourceHandler
+) : ObservableViewModel() {
+ val itemList: ObservableList = ObservableArrayList()
+ val itemIndex = ObservableField()
+ private val canMoveToNextQuestion = ObservableField(false)
+
+ val progressPercentage = ObservableField(0)
+
+ val questionProgressText: ObservableField =
+ ObservableField("$DEFAULT_QUESTION_PROGRESS%")
+
+ val questionText: ObservableField =
+ ObservableField(DEFAULT_QUESTION)
+
+ fun updateQuestionProgress(
+ progressPercentage: Int
+ ) {
+ this.progressPercentage.set(progressPercentage)
+ questionProgressText.set("$progressPercentage%")
+ }
+
+ fun updateQuestionText(questionName: SurveyQuestionName) {
+ questionText.set(getQuestionText(questionName))
+ setCanMoveToNextQuestion(false)
+ }
+
+ fun setCanMoveToNextQuestion(canMoveToNext: Boolean) =
+ this.canMoveToNextQuestion.set(canMoveToNext)
+
+ fun getCanMoveToNextQuestion(): ObservableField = canMoveToNextQuestion
+
+ fun retrievePreviousAnswer(
+ previousAnswer: SurveySelectedAnswer,
+ retrieveAnswerHandler: (List) -> PreviousAnswerHandler?
+ ) {
+ restorePreviousAnswer(
+ previousAnswer,
+ retrieveAnswerHandler(
+ itemList
+ )
+ )
+ }
+
+ private fun restorePreviousAnswer(
+ previousAnswer: SurveySelectedAnswer,
+ answerHandler: PreviousAnswerHandler?
+ ) {
+ answerHandler?.restorePreviousAnswer(previousAnswer)
+ }
+
+ private fun getQuestionText(
+ questionName: SurveyQuestionName
+ ): String {
+ val appName = resourceHandler.getStringInLocale(R.string.app_name)
+ return when (questionName) {
+ SurveyQuestionName.USER_TYPE -> resourceHandler.getStringInLocale(
+ R.string.user_type_question
+ )
+ SurveyQuestionName.MARKET_FIT -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.market_fit_question, appName
+ )
+ SurveyQuestionName.NPS -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_score_question, appName
+ )
+ SurveyQuestionName.PROMOTER_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_promoter_feedback_question, appName
+ )
+ SurveyQuestionName.PASSIVE_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_passive_feedback_question
+ )
+ SurveyQuestionName.DETRACTOR_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping(
+ R.string.nps_detractor_feedback_question
+ )
+ SurveyQuestionName.UNRECOGNIZED, SurveyQuestionName.QUESTION_NAME_UNSPECIFIED ->
+ DEFAULT_QUESTION
+ }
+ }
+
+ private companion object {
+ private const val DEFAULT_QUESTION_PROGRESS = 25
+ private const val DEFAULT_QUESTION = "some_question"
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
new file mode 100644
index 00000000000..bad65338f3b
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt
@@ -0,0 +1,105 @@
+package org.oppia.android.app.survey
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import org.oppia.android.R
+import org.oppia.android.app.fragment.FragmentComponentImpl
+import org.oppia.android.app.fragment.InjectableDialogFragment
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.util.extensions.getProto
+import org.oppia.android.util.extensions.getStringFromBundle
+import org.oppia.android.util.extensions.putProto
+import javax.inject.Inject
+
+/** Fragment that displays a fullscreen dialog for survey on-boarding. */
+class SurveyWelcomeDialogFragment : InjectableDialogFragment() {
+ @Inject
+ lateinit var surveyWelcomeDialogFragmentPresenter: SurveyWelcomeDialogFragmentPresenter
+
+ companion object {
+ internal const val PROFILE_ID_KEY = "SurveyWelcomeDialogFragment.profile_id"
+ internal const val TOPIC_ID_KEY = "SurveyWelcomeDialogFragment.topic_id"
+ internal const val EXPLORATION_ID_KEY = "SurveyWelcomeDialogFragment.exploration_id"
+ internal const val MANDATORY_QUESTION_NAMES_KEY = "SurveyWelcomeDialogFragment.question_names"
+
+ /**
+ * Creates a new instance of a DialogFragment to display the survey on-boarding message.
+ *
+ * @param profileId the ID of the profile viewing the survey prompt
+ * @return [SurveyWelcomeDialogFragment]: DialogFragment
+ */
+ fun newInstance(
+ profileId: ProfileId,
+ topicId: String,
+ explorationId: String,
+ mandatoryQuestionNames: List
+ ): SurveyWelcomeDialogFragment {
+ return SurveyWelcomeDialogFragment().apply {
+ arguments = Bundle().apply {
+ putProto(PROFILE_ID_KEY, profileId)
+ putString(TOPIC_ID_KEY, topicId)
+ putString(EXPLORATION_ID_KEY, explorationId)
+ putQuestions(MANDATORY_QUESTION_NAMES_KEY, extractQuestions(mandatoryQuestionNames))
+ }
+ }
+ }
+ }
+
+ private fun Bundle.putQuestions(name: String, nameList: IntArray) {
+ putSerializable(name, nameList)
+ }
+
+ private fun extractQuestions(questionNames: List): IntArray {
+ return questionNames.map { questionName -> questionName.number }.toIntArray()
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ (fragmentComponent as FragmentComponentImpl).inject(this)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val args =
+ checkNotNull(
+ arguments
+ ) { "Expected arguments to be passed to SurveyWelcomeDialogFragment" }
+
+ val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance())
+ val topicId = args.getStringFromBundle(TOPIC_ID_KEY)!!
+ val explorationId = args.getStringFromBundle(EXPLORATION_ID_KEY)!!
+ val surveyQuestions = args.getQuestions()
+
+ return surveyWelcomeDialogFragmentPresenter.handleCreateView(
+ inflater,
+ container,
+ profileId,
+ topicId,
+ explorationId,
+ surveyQuestions
+ )
+ }
+
+ private fun Bundle.getQuestions(): List {
+ val questionArgs = getIntArray(MANDATORY_QUESTION_NAMES_KEY)
+ return questionArgs?.map { number -> SurveyQuestionName.forNumber(number) }
+ ?: listOf()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle)
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
new file mode 100644
index 00000000000..bc24dfacd60
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt
@@ -0,0 +1,112 @@
+package org.oppia.android.app.survey
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import org.oppia.android.app.fragment.FragmentScope
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.databinding.SurveyWelcomeDialogFragmentBinding
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
+import org.oppia.android.domain.profile.ProfileManagementController
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProviders.Companion.toLiveData
+import javax.inject.Inject
+
+const val TAG_SURVEY_WELCOME_DIALOG = "SURVEY_WELCOME_DIALOG"
+
+/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */
+@FragmentScope
+class SurveyWelcomeDialogFragmentPresenter @Inject constructor(
+ private val activity: AppCompatActivity,
+ private val fragment: Fragment,
+ private val surveyController: SurveyController,
+ private val oppiaLogger: OppiaLogger,
+ private val analyticsController: AnalyticsController,
+ private val profileManagementController: ProfileManagementController
+) {
+ private lateinit var explorationId: String
+
+ /** Sets up data binding. */
+ fun handleCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ profileId: ProfileId,
+ topicId: String,
+ explorationId: String,
+ questionNames: List,
+ ): View {
+ this.explorationId = explorationId
+ val binding =
+ SurveyWelcomeDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+
+ binding.lifecycleOwner = fragment
+
+ binding.beginSurveyButton.setOnClickListener {
+ startSurveySession(profileId, topicId, questionNames)
+ }
+
+ binding.maybeLaterButton.setOnClickListener {
+ activity.supportFragmentManager.beginTransaction()
+ .remove(fragment)
+ .commitNow()
+ }
+
+ profileManagementController.updateSurveyLastShownTimestamp(profileId)
+ logSurveyPopUpShownEvent(explorationId, topicId, profileId)
+
+ return binding.root
+ }
+
+ private fun startSurveySession(
+ profileId: ProfileId,
+ topicId: String,
+ questions: List
+ ) {
+ val startDataProvider = surveyController.startSurveySession(questions, profileId = profileId)
+ startDataProvider.toLiveData().observe(
+ activity,
+ {
+ when (it) {
+ is AsyncResult.Pending ->
+ oppiaLogger.d("SurveyWelcomeDialogFragment", "Starting a survey session")
+ is AsyncResult.Failure -> {
+ oppiaLogger.e(
+ "SurveyWelcomeDialogFragment",
+ "Failed to start a survey session",
+ it.error
+ )
+ activity.finish() // Can't recover from the session failing to start.
+ }
+ is AsyncResult.Success -> {
+ oppiaLogger.d("SurveyWelcomeDialogFragment", "Successfully started a survey session")
+ val intent =
+ SurveyActivity.createSurveyActivityIntent(activity, profileId, topicId, explorationId)
+ fragment.startActivity(intent)
+ activity.finish()
+ val transaction = activity.supportFragmentManager.beginTransaction()
+ transaction.remove(fragment).commitAllowingStateLoss()
+ }
+ }
+ }
+ )
+ }
+
+ private fun logSurveyPopUpShownEvent(
+ explorationId: String,
+ topicId: String,
+ profileId: ProfileId
+ ) {
+ analyticsController.logImportantEvent(
+ oppiaLogger.createShowSurveyPopupContext(
+ explorationId,
+ topicId
+ ),
+ profileId = profileId
+ )
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt
new file mode 100644
index 00000000000..752fd524f5a
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt
@@ -0,0 +1,60 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import android.text.Editable
+import android.text.TextWatcher
+import androidx.databinding.Observable
+import androidx.databinding.ObservableField
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import javax.inject.Inject
+
+class FreeFormItemsViewModel @Inject constructor(
+ private val answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val questionName: SurveyQuestionName,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.FREE_FORM_ANSWER) {
+ var answerText: CharSequence = ""
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ answerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ answerText.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ fun getAnswerTextWatcher(): TextWatcher {
+ return object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
+ }
+
+ override fun onTextChanged(answer: CharSequence, start: Int, before: Int, count: Int) {
+ answerText = answer.toString().trim()
+ val isAnswerTextAvailable = answerText.isNotEmpty()
+ if (isAnswerTextAvailable != isAnswerAvailable.get()) {
+ isAnswerAvailable.set(isAnswerTextAvailable)
+ }
+ }
+
+ override fun afterTextChanged(s: Editable) {
+ }
+ }
+ }
+
+ fun handleSubmitButtonClicked() {
+ if (answerText.isNotEmpty()) {
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(questionName)
+ .setFreeFormAnswer(answerText.toString())
+ .build()
+ answerHandler.getFreeFormAnswer(answer)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt
new file mode 100644
index 00000000000..e2b7fe43a5e
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt
@@ -0,0 +1,153 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.Observable
+import androidx.databinding.ObservableArrayList
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.R
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.PreviousAnswerHandler
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import javax.inject.Inject
+
+/** [SurveyAnswerItemViewModel] for the market fit question options. */
+class MarketFitItemsViewModel @Inject constructor(
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.MARKET_FIT_OPTIONS), PreviousAnswerHandler {
+ val optionItems: ObservableList = getMarketFitOptions()
+
+ private val selectedItems: MutableList = mutableListOf()
+
+ override fun updateSelection(itemIndex: Int): Boolean {
+ optionItems.forEach { item -> item.isAnswerSelected.set(false) }
+ if (!selectedItems.contains(itemIndex)) {
+ selectedItems.clear()
+ selectedItems += itemIndex
+ } else {
+ selectedItems.clear()
+ }
+
+ updateIsAnswerAvailable()
+
+ if (selectedItems.isNotEmpty()) {
+ getPendingAnswer(itemIndex)
+ }
+
+ return selectedItems.isNotEmpty()
+ }
+
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ selectedItems.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ private fun updateIsAnswerAvailable() {
+ val selectedItemListWasEmpty = isAnswerAvailable.get()
+ if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) {
+ isAnswerAvailable.set(selectedItems.isNotEmpty())
+ }
+ }
+
+ private fun getPendingAnswer(itemIndex: Int) {
+ val typeCase = itemIndex + 1
+ val answerValue = MarketFitAnswer.forNumber(typeCase)
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.MARKET_FIT)
+ .setMarketFit(answerValue)
+ .build()
+ answerHandler.getMultipleChoiceAnswer(answer)
+ }
+
+ override fun getPreviousAnswer(): SurveySelectedAnswer {
+ return SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) {
+ // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out.
+ // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1)
+ // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index
+ // to get the correct typeCase to save.
+ val previousSelection = previousAnswer.marketFit.number.takeIf { it != 0 }?.minus(1)
+
+ selectedItems.apply {
+ clear()
+ previousSelection?.let { optionIndex ->
+ add(optionIndex)
+ updateIsAnswerAvailable()
+ getPendingAnswer(optionIndex)
+ optionItems[optionIndex].isAnswerSelected.set(true)
+ }
+ }
+ }
+
+ private fun getMarketFitOptions(): ObservableList {
+ val appName = resourceHandler.getStringInLocale(R.string.app_name)
+ val observableList = ObservableArrayList()
+ observableList += MarketFitAnswer.values()
+ .filter { it.isValid() }
+ .mapIndexed { index, marketFitAnswer ->
+ when (marketFitAnswer) {
+ MarketFitAnswer.VERY_DISAPPOINTED -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.market_fit_answer_very_disappointed
+ ),
+ index,
+ this
+ )
+
+ MarketFitAnswer.DISAPPOINTED -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.market_fit_answer_somewhat_disappointed
+ ),
+ index,
+ this
+ )
+
+ MarketFitAnswer.NOT_DISAPPOINTED -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.market_fit_answer_not_disappointed
+ ),
+ index,
+ this
+ )
+
+ MarketFitAnswer.NOT_APPLICABLE_WONT_USE_OPPIA_ANYMORE ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocaleWithWrapping(
+ R.string.market_fit_answer_wont_use_oppia,
+ appName
+ ),
+ index,
+ this
+ )
+ else -> throw IllegalStateException("Invalid MarketFitAnswer")
+ }
+ }
+ return observableList
+ }
+
+ companion object {
+ /** Returns whether a [MarketFitAnswer] is valid. */
+ fun MarketFitAnswer.isValid(): Boolean {
+ return when (this) {
+ MarketFitAnswer.UNRECOGNIZED, MarketFitAnswer.MARKET_FIT_ANSWER_UNSPECIFIED -> false
+ else -> true
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt
new file mode 100644
index 00000000000..be29f812ce0
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt
@@ -0,0 +1,22 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.ObservableBoolean
+import org.oppia.android.app.viewmodel.ObservableViewModel
+
+/** [ObservableViewModel] for MultipleChoiceInput values. */
+class MultipleChoiceOptionContentViewModel(
+ val optionContent: String,
+ val itemIndex: Int,
+ private val optionsViewModel: SurveyAnswerItemViewModel
+) : ObservableViewModel() {
+ var isAnswerSelected = ObservableBoolean()
+
+ fun handleItemClicked() {
+ val isCurrentlySelected = isAnswerSelected.get()
+ val shouldNowBeSelected =
+ optionsViewModel.updateSelection(itemIndex)
+ if (isCurrentlySelected != shouldNowBeSelected) {
+ isAnswerSelected.set(shouldNowBeSelected)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt
new file mode 100644
index 00000000000..1469228b49a
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt
@@ -0,0 +1,99 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.Observable
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.survey.PreviousAnswerHandler
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import org.oppia.android.app.viewmodel.ObservableArrayList
+import javax.inject.Inject
+
+class NpsItemsViewModel @Inject constructor(
+ private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.NPS_OPTIONS), PreviousAnswerHandler {
+ val optionItems: ObservableList = getNpsOptions()
+
+ private val selectedItems: MutableList = mutableListOf()
+
+ override fun updateSelection(itemIndex: Int): Boolean {
+ optionItems.forEach { item -> item.isAnswerSelected.set(false) }
+ if (!selectedItems.contains(itemIndex)) {
+ selectedItems.clear()
+ selectedItems += itemIndex
+ } else {
+ selectedItems.clear()
+ }
+
+ updateIsAnswerAvailable()
+
+ if (selectedItems.isNotEmpty()) {
+ getPendingAnswer(itemIndex)
+ }
+
+ return selectedItems.isNotEmpty()
+ }
+
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ selectedItems.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ private fun updateIsAnswerAvailable() {
+ val selectedItemListWasEmpty = isAnswerAvailable.get()
+ if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) {
+ isAnswerAvailable.set(selectedItems.isNotEmpty())
+ }
+ }
+
+ private fun getPendingAnswer(npsScore: Int) {
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.NPS)
+ .setNpsScore(npsScore)
+ .build()
+ answerHandler.getMultipleChoiceAnswer(answer)
+ }
+
+ override fun getPreviousAnswer(): SurveySelectedAnswer {
+ return SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) {
+ val selectedAnswerOption = previousAnswer.npsScore
+ selectedItems.apply {
+ clear()
+ add(selectedAnswerOption)
+ }
+
+ updateIsAnswerAvailable()
+
+ selectedAnswerOption.let { optionIndex ->
+ getPendingAnswer(optionIndex)
+ optionItems[optionIndex].isAnswerSelected.set(true)
+ }
+ }
+
+ private fun getNpsOptions(): ObservableArrayList {
+ val observableList = ObservableArrayList()
+ observableList += (0..10).mapIndexed { index, score ->
+ MultipleChoiceOptionContentViewModel(
+ optionContent = score.toString(),
+ itemIndex = index,
+ this
+ )
+ }
+ return observableList
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt
new file mode 100644
index 00000000000..85de88ec26f
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt
@@ -0,0 +1,22 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import org.oppia.android.app.viewmodel.ObservableViewModel
+
+/**
+ * The root [ObservableViewModel] for all individual items that may be displayed in the survey
+ * fragment recycler view.
+ */
+abstract class SurveyAnswerItemViewModel(val viewType: ViewType) : ObservableViewModel() {
+
+ open fun updateSelection(itemIndex: Int): Boolean {
+ return true
+ }
+
+ /** Corresponds to the type of the view model. */
+ enum class ViewType {
+ MARKET_FIT_OPTIONS,
+ USER_TYPE_OPTIONS,
+ FREE_FORM_ANSWER,
+ NPS_OPTIONS
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt
new file mode 100644
index 00000000000..14482c2775e
--- /dev/null
+++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt
@@ -0,0 +1,153 @@
+package org.oppia.android.app.survey.surveyitemviewmodel
+
+import androidx.databinding.Observable
+import androidx.databinding.ObservableField
+import androidx.databinding.ObservableList
+import org.oppia.android.R
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.app.survey.PreviousAnswerHandler
+import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver
+import org.oppia.android.app.survey.SelectedAnswerHandler
+import org.oppia.android.app.translation.AppLanguageResourceHandler
+import org.oppia.android.app.viewmodel.ObservableArrayList
+import javax.inject.Inject
+
+/** [SurveyAnswerItemViewModel] for providing the type of user question options. */
+class UserTypeItemsViewModel @Inject constructor(
+ private val resourceHandler: AppLanguageResourceHandler,
+ private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver,
+ private val answerHandler: SelectedAnswerHandler
+) : SurveyAnswerItemViewModel(ViewType.USER_TYPE_OPTIONS), PreviousAnswerHandler {
+ val optionItems: ObservableList = getUserTypeOptions()
+
+ private val selectedItems: MutableList = mutableListOf()
+
+ override fun updateSelection(itemIndex: Int): Boolean {
+ optionItems.forEach { item -> item.isAnswerSelected.set(false) }
+ if (!selectedItems.contains(itemIndex)) {
+ selectedItems.clear()
+ selectedItems += itemIndex
+ } else {
+ selectedItems.clear()
+ }
+
+ updateIsAnswerAvailable()
+
+ if (selectedItems.isNotEmpty()) {
+ getPendingAnswer(itemIndex)
+ }
+
+ return selectedItems.isNotEmpty()
+ }
+
+ val isAnswerAvailable = ObservableField(false)
+
+ init {
+ val callback: Observable.OnPropertyChangedCallback =
+ object : Observable.OnPropertyChangedCallback() {
+ override fun onPropertyChanged(sender: Observable, propertyId: Int) {
+ selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck(
+ selectedItems.isNotEmpty()
+ )
+ }
+ }
+ isAnswerAvailable.addOnPropertyChangedCallback(callback)
+ }
+
+ private fun updateIsAnswerAvailable() {
+ val selectedItemListWasEmpty = isAnswerAvailable.get()
+ if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) {
+ isAnswerAvailable.set(selectedItems.isNotEmpty())
+ }
+ }
+
+ private fun getPendingAnswer(itemIndex: Int) {
+ val typeCase = itemIndex + 1
+ val answerValue = UserTypeAnswer.forNumber(typeCase)
+ val answer = SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.USER_TYPE)
+ .setUserType(answerValue)
+ .build()
+ answerHandler.getMultipleChoiceAnswer(answer)
+ }
+
+ override fun getPreviousAnswer(): SurveySelectedAnswer {
+ return SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) {
+ // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out.
+ // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1)
+ // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index
+ // to get the correct typeCase to save.
+ val previousSelection = previousAnswer.userType.number.takeIf { it != 0 }?.minus(1)
+
+ selectedItems.apply {
+ clear()
+ previousSelection?.let { optionIndex ->
+ add(optionIndex)
+ updateIsAnswerAvailable()
+ getPendingAnswer(optionIndex)
+ optionItems[optionIndex].isAnswerSelected.set(true)
+ }
+ }
+ }
+
+ private fun getUserTypeOptions(): ObservableArrayList {
+ val observableList = ObservableArrayList()
+ observableList += UserTypeAnswer.values()
+ .filter { it.isValid() }
+ .mapIndexed { index, userTypeOption ->
+ when (userTypeOption) {
+ UserTypeAnswer.LEARNER ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_learner
+ ),
+ index,
+ this
+ )
+ UserTypeAnswer.TEACHER -> MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_teacher
+ ),
+ index,
+ this
+ )
+
+ UserTypeAnswer.PARENT ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_parent
+ ),
+ index,
+ this
+ )
+
+ UserTypeAnswer.OTHER ->
+ MultipleChoiceOptionContentViewModel(
+ resourceHandler.getStringInLocale(
+ R.string.user_type_answer_other
+ ),
+ index,
+ this
+ )
+ else -> throw IllegalStateException("Invalid UserTypeAnswer")
+ }
+ }
+ return observableList
+ }
+
+ companion object {
+
+ /** Returns whether a [UserTypeAnswer] is valid. */
+ fun UserTypeAnswer.isValid(): Boolean {
+ return when (this) {
+ UserTypeAnswer.UNRECOGNIZED, UserTypeAnswer.USER_TYPE_UNSPECIFIED -> false
+ else -> true
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt
index 33a0fb04bc4..857bf87d91a 100644
--- a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt
+++ b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt
@@ -33,7 +33,11 @@ class ChapterSummaryViewModel(
ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES -> {
if (previousChapterTitle != null) {
resourceHandler.getStringInLocaleWithWrapping(
- R.string.chapter_prerequisite_title_label, index.toString(), previousChapterTitle
+ R.string.chapter_locked_prerequisite_title_label,
+ (index + 1).toString(),
+ chapterTitle,
+ index.toString(),
+ previousChapterTitle
)
} else {
resourceHandler.getStringInLocaleWithWrapping(
diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt
index 077fd0f6aea..e6f0ee7104c 100755
--- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt
+++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt
@@ -3,11 +3,13 @@ package org.oppia.android.app.topic.revisioncard
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
+import org.oppia.android.R
import org.oppia.android.app.model.EphemeralRevisionCard
import org.oppia.android.app.model.EphemeralSubtopic
import org.oppia.android.app.model.EphemeralTopic
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.topic.RouteToRevisionCardListener
+import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.viewmodel.ObservableViewModel
import org.oppia.android.domain.oppialogger.OppiaLogger
import org.oppia.android.domain.topic.TopicController
@@ -36,6 +38,7 @@ class RevisionCardViewModel private constructor(
val topicId: String,
val subtopicId: Int,
val profileId: ProfileId,
+ private val appLanguageResourceHandler: AppLanguageResourceHandler,
val subtopicListSize: Int
) : ObservableViewModel() {
@@ -100,6 +103,21 @@ class RevisionCardViewModel private constructor(
} ?: ""
}
+ /** Returns the content description of the subtopic. */
+ fun computeContentDescriptionText(subtopicLiveData: LiveData): String {
+ return when (subtopicLiveData) {
+ previousSubtopicLiveData -> appLanguageResourceHandler.getStringInLocaleWithWrapping(
+ R.string.previous_subtopic_talkback_text,
+ computeTitleText(previousSubtopicLiveData.value)
+ )
+ nextSubtopicLiveData -> appLanguageResourceHandler.getStringInLocaleWithWrapping(
+ R.string.next_subtopic_talkback_text,
+ computeTitleText(nextSubtopicLiveData.value)
+ )
+ else -> ""
+ }
+ }
+
private fun processPreviousSubtopicData(
topicLiveData: AsyncResult
): EphemeralSubtopic {
@@ -157,6 +175,7 @@ class RevisionCardViewModel private constructor(
private val topicController: TopicController,
private val oppiaLogger: OppiaLogger,
@TopicHtmlParserEntityType private val entityType: String,
+ private val appLanguageResourceHandler: AppLanguageResourceHandler,
private val translationController: TranslationController
) {
/** Returns a new [RevisionCardViewModel]. */
@@ -175,6 +194,7 @@ class RevisionCardViewModel private constructor(
topicId,
subtopicId,
profileId,
+ appLanguageResourceHandler,
subtopicListSize
)
}
diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt
index d9883ed21c5..08726cc7f49 100644
--- a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt
+++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt
@@ -8,11 +8,14 @@ import org.oppia.android.app.customview.ContinueButtonView
import org.oppia.android.app.customview.LessonThumbnailImageView
import org.oppia.android.app.customview.PromotedStoryCardView
import org.oppia.android.app.customview.SegmentedCircularProgressView
+import org.oppia.android.app.customview.SurveyOnboardingBackgroundView
import org.oppia.android.app.home.promotedlist.ComingSoonTopicsListView
import org.oppia.android.app.home.promotedlist.PromotedStoryListView
import org.oppia.android.app.player.state.DragDropSortInteractionView
import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView
import org.oppia.android.app.player.state.SelectionInteractionView
+import org.oppia.android.app.survey.SurveyMultipleChoiceOptionView
+import org.oppia.android.app.survey.SurveyNpsItemOptionView
// TODO(#59): Restrict access to this implementation by introducing injectors in each view.
@@ -39,4 +42,7 @@ interface ViewComponentImpl : ViewComponent {
fun inject(promotedStoryCardView: PromotedStoryCardView)
fun inject(promotedStoryListView: PromotedStoryListView)
fun inject(segmentedCircularProgressView: SegmentedCircularProgressView)
+ fun inject(surveyOnboardingBackgroundView: SurveyOnboardingBackgroundView)
+ fun inject(surveyMultipleChoiceOptionView: SurveyMultipleChoiceOptionView)
+ fun inject(surveyNpsItemOptionView: SurveyNpsItemOptionView)
}
diff --git a/app/src/main/res/color/component_color_shared_survey_option_selector.xml b/app/src/main/res/color/component_color_shared_survey_option_selector.xml
new file mode 100644
index 00000000000..fe393056c34
--- /dev/null
+++ b/app/src/main/res/color/component_color_shared_survey_option_selector.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/nps_survey_button_background.xml b/app/src/main/res/drawable/nps_survey_button_background.xml
new file mode 100644
index 00000000000..a5590eebf92
--- /dev/null
+++ b/app/src/main/res/drawable/nps_survey_button_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/nps_survey_button_inactive_background.xml b/app/src/main/res/drawable/nps_survey_button_inactive_background.xml
new file mode 100644
index 00000000000..c6d563ecc99
--- /dev/null
+++ b/app/src/main/res/drawable/nps_survey_button_inactive_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_button_white_background_color.xml b/app/src/main/res/drawable/rounded_button_white_background_color.xml
new file mode 100644
index 00000000000..196f7707443
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_button_white_background_color.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_button_white_outline_color.xml b/app/src/main/res/drawable/rounded_button_white_outline_color.xml
new file mode 100644
index 00000000000..e0062e45189
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_button_white_outline_color.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml
new file mode 100644
index 00000000000..cb6db57bdcf
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/secondary_button_background.xml b/app/src/main/res/drawable/secondary_button_background.xml
index 98cd3d0fdb6..2debad327a0 100644
--- a/app/src/main/res/drawable/secondary_button_background.xml
+++ b/app/src/main/res/drawable/secondary_button_background.xml
@@ -2,7 +2,7 @@
-
+
diff --git a/app/src/main/res/drawable/state_button_primary_background.xml b/app/src/main/res/drawable/state_button_primary_background.xml
index 4b3b30c74b2..3e499d7f9cd 100644
--- a/app/src/main/res/drawable/state_button_primary_background.xml
+++ b/app/src/main/res/drawable/state_button_primary_background.xml
@@ -4,6 +4,6 @@
diff --git a/app/src/main/res/drawable/survey_confirmation_dialog_background.xml b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
new file mode 100644
index 00000000000..4ccc77e6490
--- /dev/null
+++ b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_edit_text_background_border.xml b/app/src/main/res/drawable/survey_edit_text_background_border.xml
new file mode 100644
index 00000000000..84258c626cf
--- /dev/null
+++ b/app/src/main/res/drawable/survey_edit_text_background_border.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_button_background.xml b/app/src/main/res/drawable/survey_nps_radio_button_background.xml
new file mode 100644
index 00000000000..57745803dad
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_button_background.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_selected_color.xml b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
new file mode 100644
index 00000000000..0019620da24
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_text_color.xml b/app/src/main/res/drawable/survey_nps_radio_text_color.xml
new file mode 100644
index 00000000000..80786dea646
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_text_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
new file mode 100644
index 00000000000..ac3f393f2fe
--- /dev/null
+++ b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/survey_progress_bar.xml b/app/src/main/res/drawable/survey_progress_bar.xml
new file mode 100644
index 00000000000..3eab6c3074c
--- /dev/null
+++ b/app/src/main/res/drawable/survey_progress_bar.xml
@@ -0,0 +1,20 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml
new file mode 100644
index 00000000000..b58f04d8764
--- /dev/null
+++ b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/profile_edit_fragment.xml b/app/src/main/res/layout-land/profile_edit_fragment.xml
index 09ea627140b..352b3c2ce86 100644
--- a/app/src/main/res/layout-land/profile_edit_fragment.xml
+++ b/app/src/main/res/layout-land/profile_edit_fragment.xml
@@ -108,6 +108,7 @@
android:textColor="@color/component_color_shared_primary_text_color"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profile_rename_button" />
@@ -128,6 +129,7 @@
android:textSize="16sp"
android:visibility="@{viewModel.isAllowedToMarkFinishedChapters ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/profile_reset_button" />
@@ -216,10 +218,10 @@
android:text="@string/profile_edit_allow_download_heading"
android:textColor="@color/component_color_shared_primary_text_color"
android:textSize="16sp"
+ app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/profile_edit_allow_download_switch"
app:layout_constraintHorizontal_bias="0.0"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
+ app:layout_constraintStart_toStartOf="parent" />
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/profile_edit_enable_in_lesson_language_switching_container" />
diff --git a/app/src/main/res/layout-land/resume_lesson_fragment.xml b/app/src/main/res/layout-land/resume_lesson_fragment.xml
index b620e949ba8..41675fc3d42 100644
--- a/app/src/main/res/layout-land/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout-land/resume_lesson_fragment.xml
@@ -1,7 +1,7 @@
-
+
@@ -76,36 +76,36 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/secondary_button_background"
- android:gravity="center"
android:fontFamily="sans-serif-medium"
+ android:gravity="center"
android:minWidth="144dp"
android:minHeight="@dimen/clickable_item_min_height"
+ android:text="@string/start_over_lesson_button"
android:textAllCaps="true"
android:textColor="@color/component_color_shared_secondary_button_background_trim_color"
android:textSize="14sp"
- android:text="@string/start_over_lesson_button"
app:backgroundTint="@null"
app:icon="@drawable/ic_start_over_24dp"
- app:iconTint="@color/component_color_shared_secondary_button_background_trim_color"
- app:iconGravity="textStart" />
-
+ app:iconGravity="textStart"
+ app:iconTint="@color/component_color_shared_secondary_button_background_trim_color" />
+ app:backgroundTint="@null"
+ app:icon="@drawable/ic_arrow_right_alt_24dp"
+ app:iconGravity="textEnd" />
diff --git a/app/src/main/res/layout-land/survey_fragment.xml b/app/src/main/res/layout-land/survey_fragment.xml
new file mode 100644
index 00000000000..d1358390ce6
--- /dev/null
+++ b/app/src/main/res/layout-land/survey_fragment.xml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml b/app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml
new file mode 100644
index 00000000000..9a41b4cf558
--- /dev/null
+++ b/app/src/main/res/layout-land/survey_welcome_dialog_fragment.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml b/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
index 02f728f78df..bd3bad52c20 100644
--- a/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
@@ -106,36 +106,36 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/secondary_button_background"
- android:gravity="center"
android:fontFamily="sans-serif-medium"
+ android:gravity="center"
android:minWidth="144dp"
android:minHeight="@dimen/clickable_item_min_height"
+ android:text="@string/start_over_lesson_button"
android:textAllCaps="true"
android:textColor="@color/component_color_shared_secondary_button_background_trim_color"
android:textSize="14sp"
- android:text="@string/start_over_lesson_button"
app:backgroundTint="@null"
app:icon="@drawable/ic_start_over_24dp"
- app:iconTint="@color/component_color_shared_secondary_button_background_trim_color"
- app:iconGravity="textStart" />
-
+ app:iconGravity="textStart"
+ app:iconTint="@color/component_color_shared_secondary_button_background_trim_color" />
+ app:backgroundTint="@null"
+ app:icon="@drawable/ic_arrow_right_alt_24dp"
+ app:iconGravity="textEnd" />
diff --git a/app/src/main/res/layout-sw600dp/revision_card_fragment.xml b/app/src/main/res/layout-sw600dp/revision_card_fragment.xml
index e10815ad3e1..2aa0649856d 100644
--- a/app/src/main/res/layout-sw600dp/revision_card_fragment.xml
+++ b/app/src/main/res/layout-sw600dp/revision_card_fragment.xml
@@ -33,7 +33,9 @@
+
diff --git a/app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml b/app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml
new file mode 100644
index 00000000000..d140d187690
--- /dev/null
+++ b/app/src/main/res/layout-w600dp/survey_welcome_dialog_fragment.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/lessons_in_progress_chapter_view.xml b/app/src/main/res/layout/lessons_in_progress_chapter_view.xml
index 057e550d1d1..dfd3b226eda 100644
--- a/app/src/main/res/layout/lessons_in_progress_chapter_view.xml
+++ b/app/src/main/res/layout/lessons_in_progress_chapter_view.xml
@@ -34,7 +34,7 @@
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:fontFamily="sans-serif"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minWidth="20dp"
android:minHeight="20dp"
android:text="@{viewModel.computePlayChapterIndexText()}"
@@ -59,7 +59,7 @@
android:background="@drawable/chapter_white_bg_with_bright_green_border"
android:fontFamily="sans-serif"
android:gravity="center|start"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minHeight="48dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
diff --git a/app/src/main/res/layout/lessons_locked_chapter_view.xml b/app/src/main/res/layout/lessons_locked_chapter_view.xml
index 78a04c5938e..c5d8c7d4f7e 100644
--- a/app/src/main/res/layout/lessons_locked_chapter_view.xml
+++ b/app/src/main/res/layout/lessons_locked_chapter_view.xml
@@ -35,7 +35,7 @@
android:layout_gravity="center"
android:layout_marginStart="10dp"
android:fontFamily="sans-serif"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minWidth="20dp"
android:minHeight="20dp"
android:text="@{viewModel.computePlayChapterIndexText()}"
@@ -65,7 +65,7 @@
android:background="@color/component_color_lessons_tab_activity_lessons_locked_chapter_name_background_color"
android:fontFamily="sans-serif"
android:gravity="center|start"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minHeight="48dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
diff --git a/app/src/main/res/layout/lessons_not_started_chapter_view.xml b/app/src/main/res/layout/lessons_not_started_chapter_view.xml
index f2038b60e8f..2db54e99d00 100644
--- a/app/src/main/res/layout/lessons_not_started_chapter_view.xml
+++ b/app/src/main/res/layout/lessons_not_started_chapter_view.xml
@@ -30,7 +30,7 @@
android:background="@drawable/chapter_dark_green_bg_with_bright_green_border"
android:fontFamily="sans-serif"
android:gravity="center"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minWidth="60dp"
android:minHeight="48dp"
android:paddingStart="8dp"
@@ -48,7 +48,7 @@
android:layout_height="0dp"
android:fontFamily="sans-serif"
android:gravity="center|start"
- android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}"
+ android:importantForAccessibility="no"
android:minHeight="48dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
diff --git a/app/src/main/res/layout/recently_played_fragment.xml b/app/src/main/res/layout/recently_played_fragment.xml
index e4877a7cbc8..3c61fcc166d 100644
--- a/app/src/main/res/layout/recently_played_fragment.xml
+++ b/app/src/main/res/layout/recently_played_fragment.xml
@@ -2,6 +2,13 @@
+
+
+
+
+
+ android:scrollbars="none"
+ app:data="@{viewModel.recentlyPlayedItems}" />
-
+ app:iconGravity="textStart"
+ app:iconTint="@color/component_color_shared_secondary_button_background_trim_color" />
+ app:backgroundTint="@null"
+ app:icon="@drawable/ic_arrow_right_alt_24dp"
+ app:iconGravity="textEnd" />
diff --git a/app/src/main/res/layout/revision_card_fragment.xml b/app/src/main/res/layout/revision_card_fragment.xml
index 5c1aa19d965..4377d955871 100644
--- a/app/src/main/res/layout/revision_card_fragment.xml
+++ b/app/src/main/res/layout/revision_card_fragment.xml
@@ -57,8 +57,9 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_exit_confirmation_dialog.xml b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
new file mode 100644
index 00000000000..fbbfe690df1
--- /dev/null
+++ b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_fragment.xml b/app/src/main/res/layout/survey_fragment.xml
new file mode 100644
index 00000000000..f96a442a1d9
--- /dev/null
+++ b/app/src/main/res/layout/survey_fragment.xml
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_free_form_layout.xml b/app/src/main/res/layout/survey_free_form_layout.xml
new file mode 100644
index 00000000000..fb915d5a13c
--- /dev/null
+++ b/app/src/main/res/layout/survey_free_form_layout.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_market_fit_question_layout.xml b/app/src/main/res/layout/survey_market_fit_question_layout.xml
new file mode 100644
index 00000000000..79929792d89
--- /dev/null
+++ b/app/src/main/res/layout/survey_market_fit_question_layout.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_multiple_choice_item.xml b/app/src/main/res/layout/survey_multiple_choice_item.xml
new file mode 100644
index 00000000000..5a7e2846800
--- /dev/null
+++ b/app/src/main/res/layout/survey_multiple_choice_item.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_nps_item.xml b/app/src/main/res/layout/survey_nps_item.xml
new file mode 100644
index 00000000000..d7b60e80256
--- /dev/null
+++ b/app/src/main/res/layout/survey_nps_item.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_nps_score_layout.xml b/app/src/main/res/layout/survey_nps_score_layout.xml
new file mode 100644
index 00000000000..8f84603de25
--- /dev/null
+++ b/app/src/main/res/layout/survey_nps_score_layout.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_outro_dialog_fragment.xml b/app/src/main/res/layout/survey_outro_dialog_fragment.xml
new file mode 100644
index 00000000000..0b1719efc04
--- /dev/null
+++ b/app/src/main/res/layout/survey_outro_dialog_fragment.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_user_type_question_layout.xml b/app/src/main/res/layout/survey_user_type_question_layout.xml
new file mode 100644
index 00000000000..a0fa468dde3
--- /dev/null
+++ b/app/src/main/res/layout/survey_user_type_question_layout.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/survey_welcome_dialog_fragment.xml b/app/src/main/res/layout/survey_welcome_dialog_fragment.xml
new file mode 100644
index 00000000000..abbd11759a7
--- /dev/null
+++ b/app/src/main/res/layout/survey_welcome_dialog_fragment.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/topic_fragment.xml b/app/src/main/res/layout/topic_fragment.xml
index 4fc9025e2b5..3e743f27b1d 100644
--- a/app/src/main/res/layout/topic_fragment.xml
+++ b/app/src/main/res/layout/topic_fragment.xml
@@ -17,6 +17,7 @@
android:id="@+id/topic_app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:background="@color/component_color_shared_activity_toolbar_color"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:layout_constraintBottom_toTopOf="@+id/topic_tabs_viewpager_container"
app:layout_constraintEnd_toEndOf="parent"
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 3e771dcccde..938f0dbc081 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -418,7 +418,7 @@
لا يوجد تلميح جديد متاح
إظهار الملاحظات والحل
تلميح %s
- العودة للسابق
+ العودة للسابق
الملاحظات
عرض الحل
عرض الحل
diff --git a/app/src/main/res/values-night/color_palette.xml b/app/src/main/res/values-night/color_palette.xml
index 6b9797a1a45..7bb64f94cb7 100644
--- a/app/src/main/res/values-night/color_palette.xml
+++ b/app/src/main/res/values-night/color_palette.xml
@@ -204,4 +204,25 @@
@color/color_def_onboarding_4_status_bar
@color/color_def_grape_violet
@color/color_def_bright_violet
+
+ @color/color_def_oppia_light_black
+ @color/color_def_white_f6
+ @color/color_def_oppia_turquoise
+ @color/color_def_oppia_green
+ @color/color_def_white_f5
+ @color/color_def_black_grey
+ @color/color_def_dark_green
+ @color/color_def_white
+ @color/color_def_white_f6
+ @color/color_def_dark_green
+ @color/color_def_light_blue
+ @color/color_def_white
+ @color/color_def_oppia_grayish_black
+ @color/color_def_chooser_grey
+ @color/color_def_oppia_green
+ @color/color_def_grey
+ @color/color_def_dark_green
+ @color/color_def_white
+ @color/color_def_oppia_green
+ @color/color_def_pale_green
diff --git a/app/src/main/res/values-pcm-rNG/strings.xml b/app/src/main/res/values-pcm-rNG/strings.xml
index 41dea5c3849..616d6e03f76 100644
--- a/app/src/main/res/values-pcm-rNG/strings.xml
+++ b/app/src/main/res/values-pcm-rNG/strings.xml
@@ -392,7 +392,7 @@
No new hint dey
Show hints and solution
Hint %s
- Go up
+ Go up
Hints
Show solution
Show Solution
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index a9fa5c5220b..9cb7c841127 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -380,7 +380,7 @@
Nova dica disponível
Nova dica disponível
Mostrar dicas e solução
- Navegar para cima
+ Navegar para cima
Dicas
Exibir
Revelar Solução
diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml
index 5ab6efddf76..1cf7fc4433c 100644
--- a/app/src/main/res/values-sw/strings.xml
+++ b/app/src/main/res/values-sw/strings.xml
@@ -356,7 +356,7 @@
Je, unavutiwa na:\n%s?
Kidokezo kipya kinapatikana
Onyesha vidokezo na suluhisho
- Nenda juu
+ Funga
Vidokezo
Fichua Suluhisho
Fichua Kidokezo
diff --git a/app/src/main/res/values/color_defs.xml b/app/src/main/res/values/color_defs.xml
index d99a90237e9..446a12a3a70 100644
--- a/app/src/main/res/values/color_defs.xml
+++ b/app/src/main/res/values/color_defs.xml
@@ -137,4 +137,10 @@
#AB29CC
#CC29B1
#CC2970
+ #F5F5F5
+ #F6F6F6
+ #BDCCCC
+
+ #E8E8E8
+ #E2F5F4
diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml
index 8bb2dba3b17..50e8f6caa30 100644
--- a/app/src/main/res/values/color_palette.xml
+++ b/app/src/main/res/values/color_palette.xml
@@ -244,4 +244,25 @@
@color/color_def_avatar_background_22
@color/color_def_avatar_background_23
@color/color_def_avatar_background_24
+
+ @color/color_def_white_f5
+ @color/color_def_white_f6
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_white
+ @color/color_def_white_f6
+ @color/color_def_oppia_green
+ @color/color_def_oppia_green
+ @color/color_def_white
+ @color/color_def_oppia_green
+ @color/color_def_pale_green
+ @color/color_def_light_blue
+ @color/color_def_accessible_grey
+ @color/color_def_survey_disabled_button_grey
+ @color/color_def_chooser_grey
+ @color/color_def_persian_green
+ @color/color_def_grey
diff --git a/app/src/main/res/values/component_colors.xml b/app/src/main/res/values/component_colors.xml
index 1daab233d78..f72584f767f 100644
--- a/app/src/main/res/values/component_colors.xml
+++ b/app/src/main/res/values/component_colors.xml
@@ -277,4 +277,28 @@
@color/color_palette_color_palette_walkthrough_status_bar_color
@color/color_palette_walkthrough_activity_rounded_corners_color
+
+ @color/color_palette_survey_background_color
+ @color/color_palette_survey_shared_button_color
+ @color/color_palette_survey_previous_button_text_color
+ @color/color_palette_survey_progress_bar_solid_color
+ @color/color_palette_nps_survey_button_inactive_stroke_color
+ @color/color_palette_survey_popup_header_color
+ @color/color_palette_survey_popup_message_color
+ @color/color_palette_survey_popup_background_color
+ @color/color_palette_survey_shared_button_color
+ @color/color_palette_survey_green_button_color
+ @color/color_palette_toolbar_color
+ @color/color_palette_nps_unselected_text_color
+ @color/color_palette_nps_selected_text_color
+ @color/color_palette_nps_unselected_background_color
+ @color/color_palette_nps_selected_background_color
+ @color/color_palette_primary_color
+ @color/color_palette_survey_dialog_stroke_color
+ @color/color_palette_survey_radio_button_color
+ @color/color_palette_survey_radio_button_color
+ @color/color_palette_survey_disabled_button_color
+ @color/color_palette_survey_disabled_button_text_color
+ @color/color_palette_button_text_color
+ @color/color_palette_edit_text_unselected_color
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 197f890ed67..f14ef3f518e 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -457,7 +457,7 @@
304dp
-
+
128dp
@@ -575,7 +575,7 @@
20dp
20dp
144dp
-
+
8dp
12dp
@@ -645,7 +645,7 @@
8dp
-
+
172dp
0dp
@@ -660,7 +660,7 @@
132dp
-
+
160dp
@@ -746,7 +746,7 @@
12dp
12dp
12dp
-
+
16dp
16dp
@@ -768,4 +768,22 @@
8dp
8dp
4dp
+
+
+ 12dp
+ 20dp
+ 12dp
+ 28dp
+ 28dp
+ 28dp
+ 28dp
+ 14dp
+ 4dp
+ 28sp
+ 14sp
+ 32dp
+ 32dp
+ 8dp
+ 16dp
+ 4dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 370de593631..408a4095aca 100755
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -90,6 +90,7 @@
Chapter %s with title %s is completed
Chapter %s with title %s is in progress
Complete Chapter %s: %s to unlock this chapter.
+ Chapter %s: %s is currently locked. Please complete chapter %s: %s to unlock this chapter.
Complete the previous chapter to unlock this chapter.
Enter text.
Enter a fraction in the form x/x, or a mixed number in the form x x/x.
@@ -452,7 +453,7 @@
No new hint available
Show hints and solution
Hint %s
- Navigate up
+ Close
Hints
Show solution
Show Solution
@@ -591,4 +592,37 @@
Please select all correct choices.
You may select more choices.
No more than %s choices may be selected.
+ Survey
+ Previous
+ Submit
+ Leave your feedback here
+ Continue Survey
+ Exit
+ Exit Survey
+ Are you sure you want to exit the survey?
+ Your feedback helps us serve learners like you better. Would you like to complete a short survey about your experience?
+ Begin Survey
+ Maybe Later
+ Thank you for completing the survey. We hope you\'ve enjoyed using %s!
+ Exit survey
+ We\'d love your feedback!
+ Thank you
+ 0 - Not at all likely
+ 10 - Extremely likely
+ Please select one of the following:
+ I am a learner
+ I am a teacher
+ I am a parent
+ Other
+ How would you feel if you could no longer use %s?
+ Very disappointed
+ Somewhat disappointed
+ Not disappointed
+ N/A - I don\’t use %s anymore
+ We are glad you have enjoyed your experience with %s. Please share what helped you the most:
+ Thanks for responding! How can we provide a better experience?
+ Help us improve your experience! Please share the primary reason for your score:
+ On a scale from 0–10, how likely are you to recommend %s to a friend or colleague?
+ The previous subtopic is %s
+ The next subtopic is %s
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index af9d4434c4a..af4e0b416ff 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -480,4 +480,156 @@
- 4dp
- 4dp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt
index e4515c497a9..901453c91dd 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdActivityTest.kt
@@ -32,7 +32,6 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule
import org.oppia.android.app.application.testing.TestingBuildFlavorModule
import org.oppia.android.app.devoptions.DeveloperOptionsModule
import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule
-import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.ScreenName
import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule
import org.oppia.android.app.recyclerview.RecyclerViewMatcher.Companion.hasItemCount
@@ -97,7 +96,6 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule
import org.oppia.android.util.parser.image.GlideImageLoaderModule
import org.oppia.android.util.parser.image.ImageParsingModule
-import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId
import org.oppia.android.util.system.OppiaClock
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
@@ -119,32 +117,40 @@ class ProfileAndDeviceIdActivityTest {
private const val FIXED_APPLICATION_ID = 123456789L
}
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
@get:Rule
var activityRule =
ActivityScenarioRule(
- ProfileAndDeviceIdActivity.createIntent(
- ApplicationProvider.getApplicationContext(), ProfileId.getDefaultInstance()
- )
+ ProfileAndDeviceIdActivity.createIntent(ApplicationProvider.getApplicationContext())
)
- @Inject lateinit var profileTestHelper: ProfileTestHelper
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var context: Context
- @Inject lateinit var oppiaLogger: OppiaLogger
- @Inject lateinit var oppiaClock: OppiaClock
- @Inject lateinit var networkConnectionUtil: NetworkConnectionDebugUtil
- @Inject lateinit var logUploadWorkerFactory: LogUploadWorkerFactory
- @Inject lateinit var syncStatusManager: SyncStatusManager
- private lateinit var profileId: ProfileId
+ @Inject
+ lateinit var profileTestHelper: ProfileTestHelper
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var oppiaLogger: OppiaLogger
+ @Inject
+ lateinit var oppiaClock: OppiaClock
+ @Inject
+ lateinit var networkConnectionUtil: NetworkConnectionDebugUtil
+ @Inject
+ lateinit var logUploadWorkerFactory: LogUploadWorkerFactory
+ @Inject
+ lateinit var syncStatusManager: SyncStatusManager
@Before
fun setUp() {
setUpTestApplicationComponent()
- profileId = ProfileId.newBuilder().apply { internalId = 0 }.build()
testCoroutineDispatchers.registerIdlingResource()
profileTestHelper.addOnlyAdminProfile()
+
val config = Configuration.Builder()
.setExecutor(SynchronousExecutor())
.setWorkerFactory(logUploadWorkerFactory)
@@ -171,21 +177,12 @@ class ProfileAndDeviceIdActivityTest {
@Test
fun testActivity_createIntent_verifyScreenNameInIntent() {
val screenName = ProfileAndDeviceIdActivity.createIntent(
- ApplicationProvider.getApplicationContext(), profileId
+ ApplicationProvider.getApplicationContext()
).extractCurrentAppScreenName()
assertThat(screenName).isEqualTo(ScreenName.PROFILE_AND_DEVICE_ID_ACTIVITY)
}
- @Test
- fun testActivity_createIntent_verifyProfileIdInIntent() {
- val profileId = ProfileAndDeviceIdActivity.createIntent(
- ApplicationProvider.getApplicationContext(), profileId
- ).extractCurrentUserProfileId()
-
- assertThat(profileId).isEqualTo(this.profileId)
- }
-
@Test
fun testActivity_withOnlyAdminProfile_hasOneProfileListed() {
// Verify that the fragment has actually loaded by checking to make sure there are items listed
diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt
index 7c2fc2d6534..40ed8ae48fb 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt
@@ -115,14 +115,21 @@ private const val TEST_SUB_TOPIC_ID = 1
qualifiers = "port-xxhdpi"
)
class ViewEventLogsFragmentTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
-
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var context: Context
- @Inject lateinit var oppiaLogger: OppiaLogger
- @Inject lateinit var analyticsController: AnalyticsController
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var oppiaLogger: OppiaLogger
+ @Inject
+ lateinit var analyticsController: AnalyticsController
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
@Before
fun setUp() {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt
index 9b3be9a729c..1b6a8371ba1 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt
@@ -180,7 +180,6 @@ class FaqListActivityTest {
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
-
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt
index 01ad66484df..198d64be87a 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt
@@ -232,7 +232,7 @@ class ProfilePictureActivityTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt
index a6d3864cf93..60e0fb90511 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt
@@ -184,7 +184,7 @@ class ProfileProgressActivityTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt
index f6b2fdfde21..9568acdecff 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt
@@ -8,7 +8,6 @@ import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.net.Uri
-import android.provider.MediaStore
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
@@ -24,8 +23,8 @@ import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
-import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
+import androidx.test.espresso.intent.matcher.IntentMatchers.hasType
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isClickable
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
@@ -136,7 +135,6 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule
import org.oppia.android.util.parser.image.GlideImageLoaderModule
import org.oppia.android.util.parser.image.ImageParsingModule
-import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import javax.inject.Inject
@@ -150,14 +148,26 @@ import javax.inject.Singleton
qualifiers = "port-xxhdpi"
)
class ProfileProgressFragmentTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @Inject lateinit var profileTestHelper: ProfileTestHelper
- @Inject lateinit var storyProgressTestHelper: StoryProgressTestHelper
- @Inject lateinit var context: Context
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @Inject
+ lateinit var profileTestHelper: ProfileTestHelper
+
+ @Inject
+ lateinit var storyProgressTestHelper: StoryProgressTestHelper
+
+ @Inject
+ lateinit var context: Context
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
private val internalProfileId = 0
@@ -183,7 +193,7 @@ class ProfileProgressFragmentTest {
ApplicationProvider.getApplicationContext().inject(this)
}
- private fun createProfileProgressActivityIntent(profileId: ProfileId): Intent {
+ private fun createProfileProgressActivityIntent(profileId: Int): Intent {
return ProfileProgressActivity.createProfileProgressActivityIntent(
ApplicationProvider.getApplicationContext(),
profileId
@@ -192,7 +202,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_checkProfileName_profileNameIsCorrect() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -204,7 +214,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_configChange_checkProfileName_profileNameIsCorrect() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(isRoot()).perform(orientationLandscape())
verifyItemDisplayedOnProfileProgressListItem(
@@ -218,7 +228,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_checkAccessibilityFlowIsCorrect() {
launch(
- createProfileProgressActivityIntent(profileId)
+ createProfileProgressActivityIntent(internalProfileId)
).use { scenario ->
testCoroutineDispatchers.runCurrent()
scenario.onActivity { activity ->
@@ -248,7 +258,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_configChange_checkAccessibilityFlowIsCorrect() {
launch(
- createProfileProgressActivityIntent(profileId)
+ createProfileProgressActivityIntent(internalProfileId)
).use { scenario ->
testCoroutineDispatchers.runCurrent()
onView(isRoot()).perform(orientationLandscape())
@@ -280,7 +290,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_tablet_checkAccessibilityFlowIsCorrect() {
launch(
- createProfileProgressActivityIntent(profileId)
+ createProfileProgressActivityIntent(internalProfileId)
).use { scenario ->
testCoroutineDispatchers.runCurrent()
scenario.onActivity { activity ->
@@ -309,7 +319,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_profilePictureEditDialogIsDisplayed() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.profile_edit_image)
testCoroutineDispatchers.runCurrent()
@@ -319,7 +329,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_openProfilePictureEditDialog_configChange_dialogIsStillOpen() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.profile_edit_image)
verifyTextInDialog(context.getString(R.string.profile_progress_edit_dialog_title))
@@ -331,12 +341,12 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_imageSelectAvatar_checkGalleryIntent() {
val expectedIntent: Matcher = allOf(
- hasAction(Intent.ACTION_PICK),
- hasData(MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+ hasAction(Intent.ACTION_GET_CONTENT),
+ hasType("image/*")
)
val activityResult = createGalleryPickActivityResultStub()
intending(expectedIntent).respondWith(activityResult)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.profile_edit_image)
verifyTextInDialog(context.getString(R.string.profile_progress_edit_dialog_title))
@@ -349,31 +359,38 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_imageSelectAvatar_configChange_checkGalleryIntent() {
val expectedIntent: Matcher = allOf(
- hasAction(Intent.ACTION_PICK),
- hasData(MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+ hasAction(Intent.ACTION_GET_CONTENT),
+ hasType("image/*")
)
val activityResult = createGalleryPickActivityResultStub()
intending(expectedIntent).respondWith(activityResult)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
+ onView(isRoot()).perform(orientationLandscape())
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.profile_edit_image)
verifyTextInDialog(context.getString(R.string.profile_progress_edit_dialog_title))
onView(withText(R.string.profile_picture_edit_alert_dialog_choose_from_library))
.perform(click())
- testCoroutineDispatchers.runCurrent()
intended(expectedIntent)
- onView(isRoot()).perform(orientationLandscape())
+ }
+ }
+
+ @Test
+ fun testFragment_imageSelectAvatar_configChange_profilePictureDialogIsVisible() {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
- intended(expectedIntent)
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.profile_edit_image)
- // The dialog should still be open after a configuration change.
+ verifyTextInDialog(context.getString(R.string.profile_progress_edit_dialog_title))
+ testCoroutineDispatchers.runCurrent()
+ onView(isRoot()).perform(orientationLandscape())
+ testCoroutineDispatchers.runCurrent()
verifyTextInDialog(context.getString(R.string.profile_progress_edit_dialog_title))
}
}
@Test
fun testProfileProgressFragment_noProgress_ongoingTopicCountIsZero() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -398,7 +415,7 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -423,7 +440,7 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(isRoot()).perform(orientationLandscape())
testCoroutineDispatchers.runCurrent()
@@ -437,7 +454,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_noProgress_ongoingTopicDescriptionIsCorrect() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -462,7 +479,7 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -487,7 +504,7 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(isRoot()).perform(orientationLandscape())
testCoroutineDispatchers.runCurrent()
@@ -501,7 +518,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_noProgress_completedStoriesCountIsZero() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -522,7 +539,7 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -534,7 +551,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_noProgress_completedStoriesDescriptionIsCorrect() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -554,7 +571,7 @@ class ProfileProgressFragmentTest {
profileId,
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -570,7 +587,7 @@ class ProfileProgressFragmentTest {
profileId,
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(isRoot()).perform(orientationLandscape())
testCoroutineDispatchers.runCurrent()
@@ -590,7 +607,7 @@ class ProfileProgressFragmentTest {
profileId,
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(0)).use {
testCoroutineDispatchers.runCurrent()
onView(withId(R.id.profile_progress_list)).perform(
scrollToPosition(
@@ -611,7 +628,7 @@ class ProfileProgressFragmentTest {
profileId,
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(withId(R.id.profile_progress_list)).perform(
scrollToPosition(
@@ -632,7 +649,7 @@ class ProfileProgressFragmentTest {
profileId,
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(withId(R.id.profile_progress_list)).perform(
scrollToPosition(
@@ -642,14 +659,9 @@ class ProfileProgressFragmentTest {
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 1, targetViewId = R.id.topic_name_text_view)
intended(hasComponent(TopicActivity::class.java.name))
+ intended(hasExtra(TopicActivity.getProfileIdKey(), internalProfileId))
intended(hasExtra(TopicActivity.getTopicIdKey(), FRACTIONS_TOPIC_ID))
intended(hasExtra(TopicActivity.getStoryIdKey(), FRACTIONS_STORY_ID_0))
- it.onActivity { activity ->
- assertThat(
- activity.intent.extractCurrentUserProfileId()
- .internalId
- ).isEqualTo(internalProfileId)
- }
}
}
@@ -659,7 +671,7 @@ class ProfileProgressFragmentTest {
profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build(),
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -695,7 +707,7 @@ class ProfileProgressFragmentTest {
profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build(),
timestampOlderThanOneWeek = false
)
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
verifyItemDisplayedOnProfileProgressListItem(
itemPosition = 0,
@@ -719,7 +731,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_noProgress_topicCountIsNotClickable() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(
atPositionOnView(
@@ -733,7 +745,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_noProgress_storyCountIsNotClickable() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(
atPositionOnView(
@@ -747,7 +759,7 @@ class ProfileProgressFragmentTest {
@Test
fun testProfileProgressFragment_configChange_noProgress_storyCountIsNotClickable() {
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
onView(isRoot()).perform(orientationLandscape())
testCoroutineDispatchers.runCurrent()
@@ -776,17 +788,17 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.ongoing_topics_container)
testCoroutineDispatchers.runCurrent()
intended(hasComponent(OngoingTopicListActivity::class.java.name))
- it.onActivity { activity ->
- assertThat(
- activity.intent.extractCurrentUserProfileId()
- .internalId
- ).isEqualTo(internalProfileId)
- }
+ intended(
+ hasExtra(
+ OngoingTopicListActivity.ONGOING_TOPIC_LIST_ACTIVITY_PROFILE_ID_KEY,
+ internalProfileId
+ )
+ )
}
}
@@ -801,39 +813,16 @@ class ProfileProgressFragmentTest {
timestampOlderThanOneWeek = false
)
testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
+ launch(createProfileProgressActivityIntent(internalProfileId)).use {
testCoroutineDispatchers.runCurrent()
clickProfileProgressItem(itemPosition = 0, targetViewId = R.id.completed_stories_container)
intended(hasComponent(CompletedStoryListActivity::class.java.name))
- it.onActivity { activity ->
- assertThat(
- activity.intent.extractCurrentUserProfileId()
- .internalId
- ).isEqualTo(internalProfileId)
- }
- }
- }
-
- @Test
- fun testProfileProgressFragment_createFragmentWithProfileId_verifyProfileIdInBundle() {
- storyProgressTestHelper.markCompletedRatiosStory0(
- profileId = profileId,
- timestampOlderThanOneWeek = false
- )
- storyProgressTestHelper.markCompletedFractionsStory0(
- profileId = profileId,
- timestampOlderThanOneWeek = false
- )
- testCoroutineDispatchers.runCurrent()
- launch(createProfileProgressActivityIntent(profileId)).use {
- it.onActivity { activity ->
- val fragment = activity.supportFragmentManager.findFragmentById(
- R.id.profile_progress_fragment_placeholder
+ intended(
+ hasExtra(
+ CompletedStoryListActivity.PROFILE_ID_EXTRA_KEY,
+ internalProfileId
)
- val profileId = fragment?.arguments?.extractCurrentUserProfileId()
-
- assertThat(profileId).isEqualTo(this.profileId)
- }
+ )
}
}
@@ -940,7 +929,7 @@ class ProfileProgressFragmentTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt
index 124c93b60c4..4483d5e3e6c 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt
@@ -149,15 +149,23 @@ class SplashActivityTest {
@get:Rule
val oppiaTestRule = OppiaTestRule()
- @Inject lateinit var context: Context
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var fakeMetaDataRetriever: FakeExpirationMetaDataRetriever
- @Inject lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler
- @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory
- @Inject lateinit var appStartupStateController: AppStartupStateController
-
- @Parameter lateinit var firstOpen: String
- @Parameter lateinit var secondOpen: String
+ @Inject
+ lateinit var context: Context
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var fakeMetaDataRetriever: FakeExpirationMetaDataRetriever
+ @Inject
+ lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+ @Inject
+ lateinit var appStartupStateController: AppStartupStateController
+
+ @Parameter
+ lateinit var firstOpen: String
+ @Parameter
+ lateinit var secondOpen: String
private val expirationDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
private val firstOpenFlavor by lazy { BuildFlavor.valueOf(firstOpen) }
diff --git a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
new file mode 100644
index 00000000000..158296d3aa2
--- /dev/null
+++ b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt
@@ -0,0 +1,223 @@
+package org.oppia.android.app.survey
+
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.intent.Intents
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import com.google.common.truth.Truth.assertThat
+import dagger.Component
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.R
+import org.oppia.android.app.activity.ActivityComponent
+import org.oppia.android.app.activity.ActivityComponentFactory
+import org.oppia.android.app.activity.route.ActivityRouterModule
+import org.oppia.android.app.application.ApplicationComponent
+import org.oppia.android.app.application.ApplicationInjector
+import org.oppia.android.app.application.ApplicationInjectorProvider
+import org.oppia.android.app.application.ApplicationModule
+import org.oppia.android.app.application.ApplicationStartupListenerModule
+import org.oppia.android.app.application.testing.TestingBuildFlavorModule
+import org.oppia.android.app.devoptions.DeveloperOptionsModule
+import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.ScreenName
+import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule
+import org.oppia.android.app.shim.ViewBindingShimModule
+import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule
+import org.oppia.android.data.backends.gae.NetworkConfigProdModule
+import org.oppia.android.data.backends.gae.NetworkModule
+import org.oppia.android.domain.classify.InteractionsModule
+import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule
+import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule
+import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule
+import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule
+import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule
+import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule
+import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule
+import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule
+import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule
+import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule
+import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule
+import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
+import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.exploration.ExplorationStorageModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
+import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule
+import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule
+import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.domain.question.QuestionModule
+import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
+import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2
+import org.oppia.android.domain.topic.TEST_TOPIC_ID_0
+import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule
+import org.oppia.android.testing.OppiaTestRule
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.junit.InitializeDefaultLocaleRule
+import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.accessibility.AccessibilityTestModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.caching.testing.CachingTestModule
+import org.oppia.android.util.gcsresource.GcsResourceModule
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.extractCurrentAppScreenName
+import org.oppia.android.util.logging.EventLoggingConfigurationModule
+import org.oppia.android.util.logging.LoggerModule
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule
+import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule
+import org.oppia.android.util.parser.image.GlideImageLoaderModule
+import org.oppia.android.util.parser.image.ImageParsingModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyActivity]. */
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(
+ application = SurveyActivityTest.TestApplication::class,
+ qualifiers = "port-xxhdpi"
+)
+class SurveyActivityTest {
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ private val profileId = ProfileId.newBuilder().setInternalId(0).build()
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var context: Context
+
+ @get:Rule
+ val activityTestRule = ActivityTestRule(
+ SurveyActivity::class.java,
+ /* initialTouchMode= */ true,
+ /* launchActivity= */ false
+ )
+
+ @Before
+ fun setUp() {
+ Intents.init()
+ setUpTestApplicationComponent()
+ testCoroutineDispatchers.registerIdlingResource()
+ }
+
+ @After
+ fun tearDown() {
+ testCoroutineDispatchers.unregisterIdlingResource()
+ Intents.release()
+ }
+
+ @Test
+ fun testSurveyActivity_hasCorrectActivityLabel() {
+ activityTestRule.launchActivity(createSurveyActivityIntent(profileId))
+ val title = activityTestRule.activity.title
+
+ // Verify that the activity label is correct as a proxy to verify TalkBack will announce the
+ // correct string when it's read out.
+ assertThat(title).isEqualTo(context.getString(R.string.survey_activity_title))
+ }
+
+ @Test
+ fun testActivity_createIntent_verifyScreenNameInIntent() {
+ val currentScreenNameWithIntent = SurveyActivity.createSurveyActivityIntent(
+ context, profileId, TEST_TOPIC_ID_0, TEST_EXPLORATION_ID_2
+ ).extractCurrentAppScreenName()
+
+ assertThat(currentScreenNameWithIntent).isEqualTo(ScreenName.SURVEY_ACTIVITY)
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext()
+ .inject(this)
+ }
+
+ private fun createSurveyActivityIntent(profileId: ProfileId): Intent {
+ return SurveyActivity.createSurveyActivityIntent(
+ context, profileId, TEST_TOPIC_ID_0, TEST_EXPLORATION_ID_2
+ )
+ }
+
+ // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them.
+ @Singleton
+ @Component(
+ modules = [
+ RobolectricModule::class,
+ TestPlatformParameterModule::class, PlatformParameterSingletonModule::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, QuestionModule::class, TestLogReportingModule::class,
+ AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class,
+ PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class,
+ ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class,
+ ApplicationStartupListenerModule::class, LogReportWorkerModule::class,
+ HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
+ FirebaseLogUploaderModule::class, FakeOppiaClockModule::class,
+ DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class,
+ ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class,
+ NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class,
+ AssetModule::class, LocaleProdModule::class, ActivityRecreatorTestModule::class,
+ NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
+ MathEquationInputModule::class, SplitScreenInteractionModule::class,
+ LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
+ SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
+ EventLoggingConfigurationModule::class, ActivityRouterModule::class,
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ ]
+ )
+ interface TestApplicationComponent : ApplicationComponent {
+ @Component.Builder
+ interface Builder : ApplicationComponent.Builder
+
+ fun inject(surveyActivityTest: SurveyActivityTest)
+ }
+
+ class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyActivityTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build() as TestApplicationComponent
+ }
+
+ fun inject(surveyActivityTest: SurveyActivityTest) {
+ component.inject(surveyActivityTest)
+ }
+
+ override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent {
+ return component.getActivityComponentBuilderProvider().get().setActivity(activity).build()
+ }
+
+ override fun getApplicationInjector(): ApplicationInjector = component
+ }
+}
diff --git a/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
new file mode 100644
index 00000000000..4ac59d07d5b
--- /dev/null
+++ b/app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt
@@ -0,0 +1,649 @@
+package org.oppia.android.app.survey
+
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.core.app.ActivityScenario.launch
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.contrib.RecyclerViewActions
+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.hasDescendant
+import androidx.test.espresso.matcher.ViewMatchers.isChecked
+import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.isEnabled
+import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
+import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.rule.ActivityTestRule
+import com.google.common.truth.Truth.assertThat
+import dagger.Component
+import org.hamcrest.CoreMatchers.allOf
+import org.hamcrest.CoreMatchers.not
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.R
+import org.oppia.android.app.activity.ActivityComponent
+import org.oppia.android.app.activity.ActivityComponentFactory
+import org.oppia.android.app.activity.route.ActivityRouterModule
+import org.oppia.android.app.application.ApplicationComponent
+import org.oppia.android.app.application.ApplicationInjector
+import org.oppia.android.app.application.ApplicationInjectorProvider
+import org.oppia.android.app.application.ApplicationModule
+import org.oppia.android.app.application.ApplicationStartupListenerModule
+import org.oppia.android.app.application.testing.TestingBuildFlavorModule
+import org.oppia.android.app.devoptions.DeveloperOptionsModule
+import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.ScreenName
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule
+import org.oppia.android.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView
+import org.oppia.android.app.shim.ViewBindingShimModule
+import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule
+import org.oppia.android.data.backends.gae.NetworkConfigProdModule
+import org.oppia.android.data.backends.gae.NetworkModule
+import org.oppia.android.domain.classify.InteractionsModule
+import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule
+import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule
+import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule
+import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule
+import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule
+import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule
+import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule
+import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule
+import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule
+import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule
+import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule
+import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule
+import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.exploration.ExplorationStorageModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule
+import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule
+import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule
+import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule
+import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.domain.question.QuestionModule
+import org.oppia.android.domain.survey.SurveyController
+import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule
+import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2
+import org.oppia.android.domain.topic.TEST_TOPIC_ID_0
+import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule
+import org.oppia.android.testing.FakeAnalyticsEventLogger
+import org.oppia.android.testing.OppiaTestRule
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.junit.InitializeDefaultLocaleRule
+import org.oppia.android.testing.logging.EventLogSubject
+import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClock
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.accessibility.AccessibilityTestModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.caching.testing.CachingTestModule
+import org.oppia.android.util.gcsresource.GcsResourceModule
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.extractCurrentAppScreenName
+import org.oppia.android.util.logging.EventLoggingConfigurationModule
+import org.oppia.android.util.logging.LoggerModule
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule
+import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule
+import org.oppia.android.util.parser.image.GlideImageLoaderModule
+import org.oppia.android.util.parser.image.ImageParsingModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyFragment]. */
+// FunctionName: test names are conventionally named with underscores.
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(
+ application = SurveyFragmentTest.TestApplication::class,
+ qualifiers = "port-xxhdpi"
+)
+class SurveyFragmentTest {
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @get:Rule
+ var activityTestRule: ActivityTestRule = ActivityTestRule(
+ SurveyActivity::class.java, /* initialTouchMode= */ true, /* launchActivity= */ false
+ )
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var context: Context
+
+ @Inject
+ lateinit var surveyController: SurveyController
+
+ private val profileId = ProfileId.newBuilder().setInternalId(0).build()
+
+ @Before
+ fun setup() {
+ Intents.init()
+ setUpTestApplicationComponent()
+ testCoroutineDispatchers.registerIdlingResource()
+ }
+
+ @After
+ fun tearDown() {
+ testCoroutineDispatchers.unregisterIdlingResource()
+ Intents.release()
+ }
+
+ @Test
+ fun testSurveyActivity_createIntent_verifyScreenNameInIntent() {
+ val screenName = createSurveyActivityIntent()
+ .extractCurrentAppScreenName()
+
+ assertThat(screenName).isEqualTo(ScreenName.SURVEY_ACTIVITY)
+ }
+
+ @Test
+ fun testSurveyFragment_closeButtonIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withContentDescription(R.string.survey_exit_button_description))
+ .check(
+ matches(
+ withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_progressBarIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_progress_bar))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_progressTextIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_progress_text))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ onView(withId(R.id.survey_progress_text))
+ .check(matches(withText("25%")))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_navigationContainerIsDisplayed() {
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_navigation_buttons_container))
+ .check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_initialQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withText(R.string.user_type_question))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_initialQuestion_correctOptionsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.survey_answers_recycler_view)).perform(
+ RecyclerViewActions.scrollToPosition(
+ 0
+ )
+ ).check(matches(hasDescendant(withText(R.string.user_type_answer_learner))))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_closeButtonClicked_exitConfirmationDialogDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ onView(withContentDescription(R.string.navigate_up)).perform(click())
+ onView(withText(context.getString(R.string.survey_exit_confirmation_text)))
+ .inRoot(isDialog())
+ .check(matches(isDisplayed()))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_nextButtonClicked_marketFitQuestionIsDisplayedWithCorrectOptions() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ onView(withText("How would you feel if you could no longer use Oppia?"))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(isDisplayed()))
+
+ onView(withId(R.id.survey_answers_recycler_view)).perform(
+ RecyclerViewActions.scrollToPosition(
+ 0
+ )
+ ).check(matches(hasDescendant(withText(R.string.market_fit_answer_very_disappointed))))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitMarketFitAnswer_NpsQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ onView(
+ withText(
+ "On a scale from 0–10, how likely are you to recommend Oppia to a friend" +
+ " or colleague?"
+ )
+ )
+ .check(matches(isDisplayed()))
+
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("0"))))
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("5"))))
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("6"))))
+ onView(withId(R.id.survey_answers_recycler_view))
+ .check(matches(hasDescendant(withText("10"))))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitNpsScoreOf3_detractorFeedbackQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit NpsAnswer
+ selectNpsAnswerAndMoveToNextQuestion(3)
+
+ onView(withText(R.string.nps_detractor_feedback_question))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.submit_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitNpsScoreOf8_passiveFeedbackQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit NpsAnswer
+ selectNpsAnswerAndMoveToNextQuestion(8)
+
+ onView(withText(R.string.nps_passive_feedback_question))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.submit_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button))
+ .check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button))
+ .check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testSurveyNavigation_submitNpsScoreOf10_promoterFeedbackQuestionIsDisplayed() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit marketFitAnswer
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Select and submit NpsAnswer
+ selectNpsAnswerAndMoveToNextQuestion(10)
+
+ onView(
+ withText(
+ "We are glad you have enjoyed your experience with Oppia. Please share " +
+ "what helped you the most:"
+ )
+ ).check(matches(isDisplayed()))
+
+ onView(withId(R.id.submit_button)).check(matches(isDisplayed()))
+ onView(withId(R.id.survey_previous_button)).check(matches(isDisplayed()))
+ onView(withId(R.id.survey_next_button)).check(matches(not(isDisplayed())))
+ }
+ }
+
+ @Test
+ fun testNavigation_moveToNextQuestion_thenMoveToPreviousQuestion_previousSelectionIsRestored() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ // Index 0 corresponds to "I am a learner"
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Move back to previous question
+ moveToPreviousQuestion()
+
+ // Next button is enabled if an answer is available
+ onView(withId(R.id.survey_next_button)).check(matches(isEnabled()))
+
+ onView(
+ allOf(
+ withId(R.id.multiple_choice_radio_button),
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ )
+ ).check(matches(isChecked()))
+
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_content_text_view
+ )
+ ).check(matches(withText(R.string.user_type_answer_learner)))
+ }
+ }
+
+ @Test
+ fun testNavigation_moveTwoQuestionsAhead_thenMoveToInitialQuestion_previousSelectionIsRestored() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Select and submit userTypeAnswer
+ // Index 0 corresponds to "I am a learner"
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Submit marketFitAnswer and move to question 3
+ // Index 0 corresponds to "Very Disappointed"
+ selectMultiChoiceAnswerAndMoveToNextQuestion(0)
+
+ // Move back to marketFit question
+ moveToPreviousQuestion()
+
+ // Assert marketFit answer selection is restored
+ onView(
+ allOf(
+ withId(R.id.multiple_choice_radio_button),
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ )
+ ).check(matches(isChecked()))
+
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_content_text_view
+ )
+ ).check(matches(withText(R.string.market_fit_answer_very_disappointed)))
+
+ // Move back to UserType question
+ moveToPreviousQuestion()
+
+ // Assert UserType answer selection is restored
+ onView(
+ allOf(
+ withId(R.id.multiple_choice_radio_button),
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ )
+ ).check(matches(isChecked()))
+
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = 0,
+ targetViewId = R.id.multiple_choice_content_text_view
+ )
+ ).check(matches(withText(R.string.user_type_answer_learner)))
+ }
+ }
+
+ @Test
+ fun testSurveyFragment_beginSurvey_logsBeginSurveyEvent() {
+ startSurveySession()
+ launch(
+ createSurveyActivityIntent()
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+
+ // Verify that the "begin survey" event was logged, and with the correct values.
+ val event = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(event).hasBeginSurveyContextThat {
+ hasExplorationIdThat().isEqualTo(TEST_EXPLORATION_ID_2)
+ hasTopicIdThat().isEqualTo(TEST_TOPIC_ID_0)
+ }
+ }
+ }
+
+ private fun selectNpsAnswerAndMoveToNextQuestion(npsScore: Int) {
+ onView(
+ allOf(
+ withText(npsScore.toString()),
+ isDescendantOfA(withId(R.id.survey_answers_recycler_view))
+ )
+ ).perform(click())
+ testCoroutineDispatchers.runCurrent()
+
+ onView(withId(R.id.survey_next_button)).perform(click())
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun selectMultiChoiceAnswerAndMoveToNextQuestion(choiceIndex: Int) {
+ onView(
+ atPositionOnView(
+ recyclerViewId = R.id.survey_answers_recycler_view,
+ position = choiceIndex,
+ targetViewId = R.id.multiple_choice_radio_button
+ )
+ ).perform(click())
+ testCoroutineDispatchers.runCurrent()
+
+ onView(withId(R.id.survey_next_button)).perform(click())
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun moveToPreviousQuestion() {
+ onView(withId(R.id.survey_previous_button)).perform(click())
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun startSurveySession() {
+ val questions = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ val profileId = ProfileId.newBuilder().setInternalId(1).build()
+ surveyController.startSurveySession(questions, profileId = profileId)
+ testCoroutineDispatchers.runCurrent()
+ }
+
+ private fun createSurveyActivityIntent(): Intent {
+ return SurveyActivity.createSurveyActivityIntent(
+ context = context,
+ profileId = profileId,
+ TEST_TOPIC_ID_0,
+ TEST_EXPLORATION_ID_2
+ )
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext().inject(this)
+ }
+
+ // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them.
+ @Singleton
+ @Component(
+ modules = [
+ TestPlatformParameterModule::class, RobolectricModule::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, QuestionModule::class, TestLogReportingModule::class,
+ AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class,
+ PrimeTopicAssetsControllerModule::class, ExpirationMetaDataRetrieverModule::class,
+ ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class,
+ ApplicationStartupListenerModule::class, LogReportWorkerModule::class,
+ HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
+ FirebaseLogUploaderModule::class, FakeOppiaClockModule::class,
+ DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class,
+ ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class,
+ NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class,
+ AssetModule::class, LocaleProdModule::class, ActivityRecreatorTestModule::class,
+ PlatformParameterSingletonModule::class,
+ NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
+ MathEquationInputModule::class, SplitScreenInteractionModule::class,
+ LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
+ SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
+ EventLoggingConfigurationModule::class, ActivityRouterModule::class,
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ ]
+ )
+ interface TestApplicationComponent : ApplicationComponent {
+ @Component.Builder
+ interface Builder : ApplicationComponent.Builder
+
+ fun inject(surveyFragmentTest: SurveyFragmentTest)
+ }
+
+ class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyFragmentTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build() as TestApplicationComponent
+ }
+
+ fun inject(surveyFragmentTest: SurveyFragmentTest) {
+ component.inject(surveyFragmentTest)
+ }
+
+ override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent {
+ return component.getActivityComponentBuilderProvider().get().setActivity(activity).build()
+ }
+
+ override fun getApplicationInjector(): ApplicationInjector = component
+ }
+}
diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt
index 60445c0fdf9..57de6b5b4e4 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt
@@ -155,16 +155,25 @@ import javax.inject.Singleton
qualifiers = "port-xxhdpi"
)
class TopicLessonsFragmentTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @get:Rule val oppiaTestRule = OppiaTestRule()
-
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
- @Inject lateinit var storyProgressTestHelper: StoryProgressTestHelper
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
- @Inject lateinit var fakeAccessibilityService: FakeAccessibilityService
- @Inject lateinit var spotlightStateController: SpotlightStateController
- @Inject lateinit var explorationCheckpointTestHelper: ExplorationCheckpointTestHelper
- @Inject lateinit var fakeExplorationRetriever: FakeExplorationRetriever
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val oppiaTestRule = OppiaTestRule()
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var storyProgressTestHelper: StoryProgressTestHelper
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
+ @Inject
+ lateinit var fakeAccessibilityService: FakeAccessibilityService
+ @Inject
+ lateinit var spotlightStateController: SpotlightStateController
+ @Inject
+ lateinit var explorationCheckpointTestHelper: ExplorationCheckpointTestHelper
+ @Inject
+ lateinit var fakeExplorationRetriever: FakeExplorationRetriever
@field:[Inject EnableExtraTopicTabsUi]
lateinit var enableExtraTopicTabsUiValue: PlatformParameterValue
@@ -368,7 +377,8 @@ class TopicLessonsFragmentTest {
.check(
matches(
withContentDescription(
- "Complete Chapter 1: What is a Ratio? to unlock this chapter."
+ "Chapter 2: Order is important is currently locked. Please complete chapter 1: " +
+ "What is a Ratio? to unlock this chapter."
)
)
)
diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt
index 6335fbf4ffc..0f210885768 100644
--- a/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt
+++ b/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt
@@ -21,6 +21,7 @@ import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.matcher.RootMatchers.isDialog
+import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
@@ -185,6 +186,50 @@ class RevisionCardFragmentTest {
Intents.release()
}
+ @Test
+ fun testRevisionCard_previousSubtopicTitle_whatIsAFraction_hasCorrectContentDescription() {
+ launch(
+ createRevisionCardActivityIntent(
+ context,
+ profileId.internalId,
+ FRACTIONS_TOPIC_ID,
+ subtopicId = 2,
+ FRACTIONS_SUBTOPIC_LIST_SIZE
+ )
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.prev_subtopic_title)).check(
+ matches(
+ ViewMatchers.withContentDescription(
+ "The previous subtopic is What is a Fraction?"
+ )
+ )
+ )
+ }
+ }
+
+ @Test
+ fun testRevisionCard_nextSubtopicTitle_mixedNumbers_hasCorrectContentDescription() {
+ launch(
+ createRevisionCardActivityIntent(
+ context,
+ profileId.internalId,
+ FRACTIONS_TOPIC_ID,
+ subtopicId = 2,
+ FRACTIONS_SUBTOPIC_LIST_SIZE
+ )
+ ).use {
+ testCoroutineDispatchers.runCurrent()
+ onView(withId(R.id.next_subtopic_title)).check(
+ matches(
+ ViewMatchers.withContentDescription(
+ "The next subtopic is Mixed Numbers"
+ )
+ )
+ )
+ }
+ }
+
@Test
fun testRevisionCardTest_initialise_openBottomSheet_showsBottomSheet() {
launch(
diff --git a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt
index 6fab24e75c7..66cfc7e3f9f 100644
--- a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt
+++ b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt
@@ -97,10 +97,14 @@ import javax.inject.Singleton
qualifiers = "port-xxhdpi"
)
class HomeActivityLocalTest {
- @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
+ @get:Rule
+ val initializeDefaultLocaleRule = InitializeDefaultLocaleRule()
- @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
private lateinit var profileId: ProfileId
diff --git a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt
index 2d350b866c1..949a6e52e0f 100644
--- a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt
+++ b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt
@@ -224,7 +224,7 @@ class ExplorationActivityLocalTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt b/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt
index 5db1a12b695..82ae2bc2ef8 100644
--- a/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt
+++ b/app/src/test/java/org/oppia/android/app/translation/ActivityLanguageLocaleHandlerTest.kt
@@ -128,10 +128,13 @@ class ActivityLanguageLocaleHandlerTest {
@Inject
lateinit var context: Context
+
@Inject
lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler
+
@Inject
lateinit var translationController: TranslationController
+
@Inject
lateinit var monitorFactory: DataProviderTestMonitor.Factory
@@ -327,7 +330,7 @@ class ActivityLanguageLocaleHandlerTest {
LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
- CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class,
+ CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class
]
)
diff --git a/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt b/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt
index 9cc8ae2c66b..926ea83dbec 100644
--- a/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt
+++ b/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt
@@ -90,12 +90,16 @@ import javax.inject.Singleton
// Time Tue, 23 April 2019 21:26:12
private const val EVENING_TIMESTAMP = 1556054772000
+
// Time: Tue, Apr 23 2019 23:22:00
private const val LATE_NIGHT_TIMESTAMP = 1556061720000
+
// Time: Wed, Apr 24 2019 08:22:00
private const val EARLY_MORNING_TIMESTAMP = 1556094120000
+
// Time: Wed, 24 April 2019 10:30:12
private const val MID_MORNING_TIMESTAMP = 1556101812000
+
// Time: Tue, Apr 23 2019 14:22:00
private const val AFTERNOON_TIMESTAMP = 1556029320000
@@ -109,6 +113,7 @@ class DateTimeUtilTest {
@Inject
lateinit var context: Context
+
@Inject
lateinit var fakeOppiaClock: FakeOppiaClock
@@ -224,7 +229,7 @@ class DateTimeUtilTest {
SyncStatusModule::class, TestingBuildFlavorModule::class,
EventLoggingConfigurationModule::class, ActivityRouterModule::class,
CpuPerformanceSnapshotterModule::class, AnalyticsStartupListenerTestModule::class,
- ExplorationProgressModule::class
+ ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : ApplicationComponent {
diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel
index 1590004cf89..79bcd289f08 100755
--- a/domain/BUILD.bazel
+++ b/domain/BUILD.bazel
@@ -199,6 +199,7 @@ TEST_DEPS = [
"//domain/src/main/java/org/oppia/android/domain/classify/rules/textinput:text_input_rule_module",
"//domain/src/main/java/org/oppia/android/domain/feedbackreporting:prod_module",
"//domain/src/main/java/org/oppia/android/domain/feedbackreporting:report_schema_version",
+ "//domain/src/main/java/org/oppia/android/domain/onboarding:deprecation_controller",
"//domain/src/main/java/org/oppia/android/domain/onboarding:retriever_prod_module",
"//domain/src/main/java/org/oppia/android/domain/onboarding:state_controller",
"//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel
index f3ba2025c05..ae8e6e2d0ef 100644
--- a/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel
+++ b/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel
@@ -9,14 +9,17 @@ kt_android_library(
name = "state_controller",
srcs = [
"AppStartupStateController.kt",
+ "DeprecationController.kt",
],
visibility = ["//:oppia_api_visibility"],
deps = [
":exploration_meta_data_retriever",
"//data/src/main/java/org/oppia/android/data/persistence:cache_store",
"//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
+ "//model/src/main/proto:deprecation_java_proto_lite",
"//model/src/main/proto:onboarding_java_proto_lite",
"//third_party:javax_inject_javax_inject",
+ "//utility",
"//utility/src/main/java/org/oppia/android/util/data:data_provider",
"//utility/src/main/java/org/oppia/android/util/data:data_providers",
"//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions",
@@ -55,4 +58,24 @@ kt_android_library(
],
)
+kt_android_library(
+ name = "deprecation_controller",
+ srcs = [
+ "DeprecationController.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ ":exploration_meta_data_retriever",
+ "//data/src/main/java/org/oppia/android/data/persistence:cache_store",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
+ "//model/src/main/proto:deprecation_java_proto_lite",
+ "//model/src/main/proto:onboarding_java_proto_lite",
+ "//third_party:javax_inject_javax_inject",
+ "//utility",
+ "//utility/src/main/java/org/oppia/android/util/data:data_provider",
+ "//utility/src/main/java/org/oppia/android/util/data:data_providers",
+ "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt
new file mode 100644
index 00000000000..d798fe83b62
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt
@@ -0,0 +1,117 @@
+package org.oppia.android.domain.onboarding
+
+import kotlinx.coroutines.Deferred
+import org.oppia.android.app.model.DeprecationNoticeType
+import org.oppia.android.app.model.DeprecationResponse
+import org.oppia.android.app.model.DeprecationResponseDatabase
+import org.oppia.android.data.persistence.PersistentCacheStore
+import org.oppia.android.domain.oppialogger.OppiaLogger
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProvider
+import org.oppia.android.util.data.DataProviders
+import org.oppia.android.util.data.DataProviders.Companion.transform
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val GET_DEPRECATION_RESPONSE_PROVIDER_ID = "get_deprecation_response_provider_id"
+private const val ADD_DEPRECATION_RESPONSE_PROVIDER_ID = "add_deprecation_response_provider_id"
+
+/**
+ * Controller for persisting and retrieving the user's deprecation responses. This will be used to
+ * handle deprecations once the user opens the app.
+ */
+@Singleton
+class DeprecationController @Inject constructor(
+ cacheStoreFactory: PersistentCacheStore.Factory,
+ private val oppiaLogger: OppiaLogger,
+ private val dataProviders: DataProviders
+) {
+ /** Create an instance of [PersistentCacheStore] that contains a [DeprecationResponseDatabase]. */
+ private val deprecationStore by lazy {
+ cacheStoreFactory.create(
+ "deprecation_store",
+ DeprecationResponseDatabase.getDefaultInstance()
+ )
+ }
+
+ /** Enum states for the possible outcomes of a deprecation action. */
+ private enum class DeprecationResponseActionStatus {
+ /** Indicates that the deprecation response read/write operation succeeded. */
+ SUCCESS
+ }
+
+ init {
+ // Prime the cache ahead of time so that the deprecation response can be retrieved
+ // synchronously.
+ deprecationStore.primeInMemoryAndDiskCacheAsync(
+ updateMode = PersistentCacheStore.UpdateMode.UPDATE_ALWAYS,
+ publishMode = PersistentCacheStore.PublishMode.PUBLISH_TO_IN_MEMORY_CACHE
+ ).invokeOnCompletion { primeFailure ->
+ primeFailure?.let {
+ oppiaLogger.e(
+ "DeprecationController",
+ "Failed to prime cache ahead of data retrieval for DeprecationController.",
+ primeFailure
+ )
+ }
+ }
+ }
+
+ private val deprecationDataProvider by lazy { fetchDeprecationProvider() }
+
+ private fun fetchDeprecationProvider(): DataProvider {
+ return deprecationStore.transform(
+ GET_DEPRECATION_RESPONSE_PROVIDER_ID
+ ) { deprecationResponsesDatabase ->
+ DeprecationResponseDatabase.newBuilder().apply {
+ appDeprecationResponse = deprecationResponsesDatabase.appDeprecationResponse
+ osDeprecationResponse = deprecationResponsesDatabase.osDeprecationResponse
+ }.build()
+ }
+ }
+
+ /**
+ * Returns a [DataProvider] containing the the [DeprecationResponseDatabase], which in turn
+ * affects what initial app flow the user is directed to.
+ */
+ fun getDeprecationDatabase(): DataProvider = deprecationDataProvider
+
+ /**
+ * Stores a new [DeprecationResponse] to the cache.
+ *
+ * @param deprecationResponse the deprecation response to be stored
+ * @return [AsyncResult] of the deprecation action
+ */
+ fun saveDeprecationResponse(deprecationResponse: DeprecationResponse): DataProvider {
+ val deferred = deprecationStore.storeDataWithCustomChannelAsync(
+ updateInMemoryCache = true
+ ) { deprecationResponseDb ->
+ val deprecationBuilder = deprecationResponseDb.toBuilder().apply {
+ if (deprecationResponse.deprecationNoticeType == DeprecationNoticeType.APP_DEPRECATION)
+ appDeprecationResponse = deprecationResponse
+ else
+ osDeprecationResponse = deprecationResponse
+ }
+ .build()
+ Pair(deprecationBuilder, DeprecationResponseActionStatus.SUCCESS)
+ }
+
+ return dataProviders.createInMemoryDataProviderAsync(ADD_DEPRECATION_RESPONSE_PROVIDER_ID) {
+ return@createInMemoryDataProviderAsync getDeferredResult(deferred)
+ }
+ }
+
+ /**
+ * Retrieves the [DeprecationResponse] from the cache.
+ *
+ * @param deferred a deferred instance of the [DeprecationResponseActionStatus]
+ * @return [AsyncResult]
+ */
+ private suspend fun getDeferredResult(
+ deferred: Deferred
+ ): AsyncResult {
+ return when (deferred.await()) {
+ DeprecationResponseActionStatus.SUCCESS -> AsyncResult.Success(null)
+ }
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt
index 61055454ff0..bc94cb00df1 100644
--- a/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt
+++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/OppiaLogger.kt
@@ -216,4 +216,38 @@ class OppiaLogger @Inject constructor(private val consoleLogger: ConsoleLogger)
this.subTopicId = subtopicIndex
}.build()
}
+
+ /**
+ * Returns the context of the event indicating that the user saw the survey popup dialog.
+ */
+ fun createShowSurveyPopupContext(
+ explorationId: String,
+ topicId: String,
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setShowSurveyPopup(
+ EventLog.SurveyContext.newBuilder()
+ .setExplorationId(explorationId)
+ .setTopicId(topicId)
+ .build()
+ )
+ .build()
+ }
+
+ /**
+ * Returns the context of the event indicating that the user began a survey session.
+ */
+ fun createBeginSurveyContext(
+ explorationId: String,
+ topicId: String,
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setBeginSurvey(
+ EventLog.SurveyContext.newBuilder()
+ .setExplorationId(explorationId)
+ .setTopicId(topicId)
+ .build()
+ )
+ .build()
+ }
}
diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel
new file mode 100644
index 00000000000..1568c5914b9
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/BUILD.bazel
@@ -0,0 +1,20 @@
+"""
+Library for providing logging functionality in a survey.
+"""
+
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
+load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library")
+
+kt_android_library(
+ name = "survey_events_logger",
+ srcs = ["SurveyEventsLogger.kt"],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ "//domain",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger",
+ "//model/src/main/proto:survey_java_proto_lite",
+ "//third_party:javax_inject_javax_inject",
+ ],
+)
+
+dagger_rules()
diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt
new file mode 100644
index 00000000000..7e1178b0ca0
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/survey/SurveyEventsLogger.kt
@@ -0,0 +1,98 @@
+package org.oppia.android.domain.oppialogger.survey
+
+import org.oppia.android.app.model.EventLog
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Convenience logger for survey events.
+ *
+ * This logger is meant to be used directly in places where survey events have to be logged
+ */
+@Singleton
+class SurveyEventsLogger @Inject constructor(
+ private val analyticsController: AnalyticsController,
+) {
+
+ /**
+ * Logs an event representing a survey session being started and ended before the
+ * mandatory questions are completed.
+ */
+ fun logAbandonSurvey(surveyId: String, profileId: ProfileId, questionName: SurveyQuestionName) {
+ analyticsController.logImportantEvent(
+ createAbandonSurveyContext(surveyId, profileId, questionName),
+ profileId
+ )
+ }
+
+ /** Logs an event representing the responses to the m sandatory survey questions. */
+ fun logMandatoryResponses(
+ surveyId: String,
+ profileId: ProfileId,
+ userTypeAnswer: UserTypeAnswer,
+ marketFitAnswer: MarketFitAnswer,
+ npsScore: Int
+ ) {
+ analyticsController.logImportantEvent(
+ createMandatorySurveyResponseContext(
+ surveyId,
+ profileId,
+ userTypeAnswer,
+ marketFitAnswer,
+ npsScore
+ ),
+ profileId
+ )
+ }
+
+ private fun createMandatorySurveyResponseContext(
+ surveyId: String,
+ profileId: ProfileId,
+ userTypeAnswer: UserTypeAnswer,
+ marketFitAnswer: MarketFitAnswer,
+ npsScore: Int
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setMandatoryResponse(
+ EventLog.MandatorySurveyResponseContext.newBuilder()
+ .setUserTypeAnswer(userTypeAnswer)
+ .setMarketFitAnswer(marketFitAnswer)
+ .setNpsScoreAnswer(npsScore)
+ .setSurveyDetails(
+ createSurveyResponseContext(surveyId, profileId)
+ )
+ )
+ .build()
+ }
+
+ private fun createAbandonSurveyContext(
+ surveyId: String,
+ profileId: ProfileId,
+ questionName: SurveyQuestionName
+ ): EventLog.Context {
+ return EventLog.Context.newBuilder()
+ .setAbandonSurvey(
+ EventLog.AbandonSurveyContext.newBuilder()
+ .setQuestionName(questionName)
+ .setSurveyDetails(
+ createSurveyResponseContext(surveyId, profileId)
+ )
+ )
+ .build()
+ }
+
+ private fun createSurveyResponseContext(
+ surveyId: String,
+ profileId: ProfileId
+ ): EventLog.SurveyResponseContext {
+ return EventLog.SurveyResponseContext.newBuilder()
+ .setProfileId(profileId.internalId.toString())
+ .setSurveyId(surveyId)
+ .build()
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt
index 53de75416ae..1b3293d56f5 100644
--- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt
@@ -40,7 +40,7 @@ import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
-import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
@@ -297,7 +297,7 @@ class PlatformParameterAlphaKenyaModule {
return platformParameterSingleton.getIntegerPlatformParameter(
NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
) ?: PlatformParameterValue.createDefaultParameter(
- NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
)
}
}
diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt
index 29307b62b1c..8addae7b9fd 100644
--- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaModule.kt
@@ -38,6 +38,12 @@ import org.oppia.android.util.platformparameter.LOGGING_LEARNER_STUDY_IDS_DEFAUL
import org.oppia.android.util.platformparameter.LOWEST_SUPPORTED_API_LEVEL
import org.oppia.android.util.platformparameter.LOWEST_SUPPORTED_API_LEVEL_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
+import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS
+import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
+import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
import org.oppia.android.util.platformparameter.OptionalAppUpdateVersionCode
import org.oppia.android.util.platformparameter.PERFORMANCE_METRICS_COLLECTION_HIGH_FREQUENCY_TIME_INTERVAL_IN_MINUTES
@@ -263,4 +269,28 @@ class PlatformParameterAlphaModule {
LOWEST_SUPPORTED_API_LEVEL_DEFAULT_VALUE
)
}
+
+ @Provides
+ @NpsSurveyGracePeriodInDays
+ fun provideNpsSurveyGracePeriodInDays(
+ platformParameterSingleton: PlatformParameterSingleton
+ ): PlatformParameterValue {
+ return platformParameterSingleton.getIntegerPlatformParameter(
+ NPS_SURVEY_GRACE_PERIOD_IN_DAYS
+ ) ?: PlatformParameterValue.createDefaultParameter(
+ NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
+ )
+ }
+
+ @Provides
+ @NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
+ fun provideNpsSurveyMinimumAggregateLearningTimeInATopicInMinutes(
+ platformParameterSingleton: PlatformParameterSingleton
+ ): PlatformParameterValue {
+ return platformParameterSingleton.getIntegerPlatformParameter(
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
+ ) ?: PlatformParameterValue.createDefaultParameter(
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
+ )
+ }
}
diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt
index 53d9d5b0fb0..c2a211472fc 100644
--- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt
+++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt
@@ -43,7 +43,7 @@ import org.oppia.android.util.platformparameter.LowestSupportedApiLevel
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS
import org.oppia.android.util.platformparameter.NPS_SURVEY_GRACE_PERIOD_IN_DAYS_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
-import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+import org.oppia.android.util.platformparameter.NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
import org.oppia.android.util.platformparameter.NpsSurveyGracePeriodInDays
import org.oppia.android.util.platformparameter.NpsSurveyMinimumAggregateLearningTimeInATopicInMinutes
import org.oppia.android.util.platformparameter.OPTIONAL_APP_UPDATE_VERSION_CODE
@@ -295,7 +295,7 @@ class PlatformParameterModule {
return platformParameterSingleton.getIntegerPlatformParameter(
NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES
) ?: PlatformParameterValue.createDefaultParameter(
- NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VAL
+ NPS_SURVEY_MINIMUM_AGGREGATE_LEARNING_TIME_IN_A_TOPIC_IN_MINUTES_DEFAULT_VALUE
)
}
}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
index 0d2409f6857..7e6da66527e 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
+++ b/domain/src/main/java/org/oppia/android/domain/survey/BUILD.bazel
@@ -2,6 +2,7 @@
Library for providing survey functionality in the app.
"""
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library")
kt_android_library(
@@ -18,3 +19,46 @@ kt_android_library(
"//utility/src/main/java/org/oppia/android/util/system:oppia_clock",
],
)
+
+kt_android_library(
+ name = "survey_controller",
+ srcs = [
+ "SurveyController.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ ":survey_progress_controller",
+ ],
+)
+
+kt_android_library(
+ name = "survey_progress_controller",
+ srcs = [
+ "SurveyProgressController.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ ":survey_progress",
+ "//domain",
+ "//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller",
+ "//third_party:javax_inject_javax_inject",
+ "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale",
+ "//utility/src/main/java/org/oppia/android/util/system:oppia_clock",
+ ],
+)
+
+kt_android_library(
+ name = "survey_progress",
+ srcs = [
+ "SurveyProgress.kt",
+ "SurveyQuestionDeck.kt",
+ "SurveyQuestionGraph.kt",
+ ],
+ visibility = ["//:oppia_api_visibility"],
+ deps = [
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/survey:survey_events_logger",
+ "//model/src/main/proto:survey_java_proto_lite",
+ ],
+)
+
+dagger_rules()
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
new file mode 100644
index 00000000000..13ee41e96c9
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt
@@ -0,0 +1,129 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.Survey
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProvider
+import org.oppia.android.util.data.DataProviders
+import org.oppia.android.util.data.DataProviders.Companion.combineWith
+import org.oppia.android.util.data.DataProviders.Companion.transform
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val CREATE_SURVEY_PROVIDER_ID = "create_survey_provider_id"
+private const val START_SURVEY_SESSION_PROVIDER_ID = "start_survey_session_provider_id"
+private const val CREATE_QUESTIONS_LIST_PROVIDER_ID = "create_questions_list_provider_id"
+
+/**
+ * Controller for creating and retrieving all attributes of a survey.
+ *
+ * Only one survey is shown at a time, and its progress is controlled by the
+ * [SurveyProgressController].
+ */
+@Singleton
+class SurveyController @Inject constructor(
+ private val dataProviders: DataProviders,
+ private val surveyProgressController: SurveyProgressController,
+ private val exceptionsController: ExceptionsController
+) {
+ private val surveyId = UUID.randomUUID().toString()
+
+ /**
+ * Starts a new survey session with a list of questions.
+ *
+ * @property mandatoryQuestionNames a list of uniques names of the questions that will be
+ * generated for this survey. Callers should be aware that the order of questions is important as
+ * the list will be indexed and displayed in the provided order.
+ * @return a [DataProvider] indicating whether the session start was successful
+ */
+ fun startSurveySession(
+ mandatoryQuestionNames: List,
+ showOptionalQuestion: Boolean = true,
+ profileId: ProfileId
+ ): DataProvider {
+ return try {
+ val createSurveyDataProvider =
+ createSurvey(mandatoryQuestionNames, showOptionalQuestion)
+ val questionsListDataProvider =
+ createSurveyDataProvider.transform(CREATE_QUESTIONS_LIST_PROVIDER_ID) { survey ->
+ if (survey.hasOptionalQuestion()) {
+ survey.mandatoryQuestionsList + survey.optionalQuestion
+ } else survey.mandatoryQuestionsList
+ }
+
+ val beginSessionDataProvider =
+ surveyProgressController.beginSurveySession(surveyId, profileId, questionsListDataProvider)
+
+ beginSessionDataProvider.combineWith(
+ createSurveyDataProvider, START_SURVEY_SESSION_PROVIDER_ID
+ ) { sessionResult, _ -> sessionResult }
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ dataProviders.createInMemoryDataProviderAsync(START_SURVEY_SESSION_PROVIDER_ID) {
+ AsyncResult.Failure(e)
+ }
+ }
+ }
+
+ private fun createSurvey(
+ mandatoryQuestionNames: List,
+ showOptionalQuestion: Boolean
+ ): DataProvider {
+ val mandatoryQuestionsList = mandatoryQuestionNames.mapIndexed { index, questionName ->
+ createSurveyQuestion(index.toString(), questionName)
+ }
+ // The questionId corresponds to the order of the questions in list, so the optional question
+ // will always come at the end of the list.
+ val surveyBuilder = Survey.newBuilder()
+ .setSurveyId(surveyId)
+ .addAllMandatoryQuestions(mandatoryQuestionsList)
+
+ if (showOptionalQuestion) {
+ surveyBuilder.optionalQuestion =
+ createDefaultFeedbackQuestion(mandatoryQuestionsList.size.toString())
+ }
+
+ return dataProviders.createInMemoryDataProvider(CREATE_SURVEY_PROVIDER_ID) {
+ surveyBuilder.build()
+ }
+ }
+
+ private fun createSurveyQuestion(
+ questionId: String,
+ questionName: SurveyQuestionName
+ ): SurveyQuestion {
+ return SurveyQuestion.newBuilder()
+ .setQuestionId(questionId)
+ .setQuestionName(questionName)
+ .build()
+ }
+
+ private fun createDefaultFeedbackQuestion(
+ questionId: String
+ ): SurveyQuestion {
+ return SurveyQuestion.newBuilder()
+ .setQuestionId(questionId)
+ .setQuestionName(SurveyQuestionName.PROMOTER_FEEDBACK)
+ .setFreeFormText(true)
+ .build()
+ }
+
+ /**
+ * Finishes the most recent session started by [startSurveySession].
+ *
+ * This method should only be called if there is an active session, otherwise the
+ * resulting provider will fail. Note that this doesn't actually need to be called between
+ * sessions unless the caller wants to ensure other providers monitored from
+ * [SurveyProgressController] are reset to a proper out-of-session state.
+ *
+ * Note that the returned provider monitors the long-term stopping state of survey sessions and
+ * will be reset to 'pending' when a session is currently active, or before any session has
+ * started.
+ */
+ fun stopSurveySession(surveyCompleted: Boolean): DataProvider =
+ surveyProgressController.endSurveySession(surveyCompleted)
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt
index b7e8ba6fb65..fcb790a0245 100644
--- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt
@@ -38,8 +38,8 @@ class SurveyGatingController @Inject constructor(
)
/**
- * Returns a data provider containing the outcome of gating, which will be used by callers to
- * determine if a survey can be shown.
+ * Returns a data provider containing a boolean outcome of gating, which informs callers whether
+ * a survey can be shown.
*/
fun maybeShowSurvey(profileId: ProfileId, topicId: String): DataProvider {
val lastShownDateProvider = retrieveSurveyLastShownDate(profileId)
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt
new file mode 100644
index 00000000000..e25cc912303
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt
@@ -0,0 +1,112 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.SurveyQuestion
+
+/**
+ * Private class that encapsulates the mutable state of a survey progress controller.
+ * This class is not thread-safe, so owning classes should ensure synchronized access.
+ */
+class SurveyProgress {
+ var surveyStage: SurveyStage = SurveyStage.NOT_IN_SURVEY_SESSION
+ private var questionsList: List = mutableListOf()
+ private var isTopQuestionCompleted: Boolean = false
+ val questionGraph: SurveyQuestionGraph by lazy {
+ SurveyQuestionGraph(questionsList as MutableList)
+ }
+ val questionDeck: SurveyQuestionDeck by lazy {
+ SurveyQuestionDeck(getTotalQuestionCount(), getInitialQuestion(), this::isTopQuestionTerminal)
+ }
+
+ /** Initialize the survey with the specified list of questions. */
+ fun initialize(questionsList: List) {
+ advancePlayStageTo(SurveyStage.VIEWING_SURVEY_QUESTION)
+ this.questionsList = questionsList
+ isTopQuestionCompleted = false
+ }
+
+ /** Returns the index of the current question being viewed. */
+ private fun getCurrentQuestionIndex(): Int {
+ return questionDeck.getTopQuestionIndex()
+ }
+
+ /** Returns the first question in the list. */
+ private fun getInitialQuestion(): SurveyQuestion = questionsList.first()
+
+ /** Returns the number of questions in the survey. */
+ fun getTotalQuestionCount(): Int {
+ return questionsList.size
+ }
+
+ /** Update the question at the current position of the deck. */
+ fun refreshDeck() {
+ questionDeck.updateDeck(questionGraph.getQuestion(getCurrentQuestionIndex()))
+ }
+
+ /**
+ * Advances the current play stage to the specified stage, verifying that the transition is correct.
+ *
+ * Calling code should prevent this method from failing by checking state ahead of calling this method and providing
+ * more useful errors to UI calling code since errors thrown by this method will be more obscure. This method aims to
+ * ensure the internal state of the controller remains correct. This method is not meant to be covered in unit tests
+ * since none of the failures here should ever be exposed to controller callers.
+ */
+ fun advancePlayStageTo(nextStage: SurveyStage) {
+ when (nextStage) {
+ SurveyStage.NOT_IN_SURVEY_SESSION -> {
+ // All transitions to NOT_IN_SURVEY_SESSION are valid except those originating from itself.
+ check(surveyStage != SurveyStage.NOT_IN_SURVEY_SESSION) {
+ "Cannot transition to NOT_IN_TRAINING_SESSION from NOT_IN_TRAINING_SESSION"
+ }
+ surveyStage = nextStage
+ }
+ SurveyStage.LOADING_SURVEY_SESSION -> {
+ // A session can only start being loaded when not previously in a session.
+ check(surveyStage == SurveyStage.NOT_IN_SURVEY_SESSION) {
+ "Cannot transition to LOADING_SURVEY_SESSION from $surveyStage"
+ }
+ surveyStage = nextStage
+ }
+ SurveyStage.VIEWING_SURVEY_QUESTION -> {
+ // A question can be viewed after loading a survey session, after viewing another question,
+ // or after submitting an answer. It cannot be viewed without a loaded session.
+ check(
+ surveyStage == SurveyStage.LOADING_SURVEY_SESSION ||
+ surveyStage == SurveyStage.VIEWING_SURVEY_QUESTION ||
+ surveyStage == SurveyStage.SUBMITTING_ANSWER
+ ) {
+ "Cannot transition to VIEWING_SURVEY_QUESTION from $surveyStage"
+ }
+ surveyStage = nextStage
+ }
+ SurveyStage.SUBMITTING_ANSWER -> {
+ // An answer can only be submitted after viewing a question.
+ check(surveyStage == SurveyStage.VIEWING_SURVEY_QUESTION) {
+ "Cannot transition to SUBMITTING_ANSWER from $surveyStage"
+ }
+ surveyStage = nextStage
+ }
+ }
+ }
+
+ private fun isTopQuestionTerminal(
+ @Suppress("UNUSED_PARAMETER") surveyQuestion: SurveyQuestion
+ ): Boolean {
+ return questionDeck.isCurrentQuestionTopOfDeck() &&
+ getCurrentQuestionIndex() == getTotalQuestionCount().minus(1)
+ }
+
+ /** Different stages in which the progress controller can exist. */
+ enum class SurveyStage {
+ /** No session is currently ongoing. */
+ NOT_IN_SURVEY_SESSION,
+
+ /** A survey is currently being prepared. */
+ LOADING_SURVEY_SESSION,
+
+ /** The controller is currently viewing a SurveyQuestion. */
+ VIEWING_SURVEY_QUESTION,
+
+ /** The controller is in the process of submitting an answer. */
+ SUBMITTING_ANSWER
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
new file mode 100644
index 00000000000..b7a612b2c1a
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt
@@ -0,0 +1,741 @@
+package org.oppia.android.domain.survey
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SelectedAnswerDatabase
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.data.persistence.PersistentCacheStore
+import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController
+import org.oppia.android.domain.oppialogger.survey.SurveyEventsLogger
+import org.oppia.android.util.data.AsyncResult
+import org.oppia.android.util.data.DataProvider
+import org.oppia.android.util.data.DataProviders
+import org.oppia.android.util.data.DataProviders.Companion.combineWith
+import org.oppia.android.util.data.DataProviders.Companion.transformAsync
+import org.oppia.android.util.data.DataProviders.Companion.transformNested
+import org.oppia.android.util.threading.BackgroundDispatcher
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val BEGIN_SESSION_RESULT_PROVIDER_ID = "SurveyProgressController.begin_session_result"
+private const val EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID =
+ "SurveyProgressController.create_empty_questions_list_data_provider_id"
+private const val MONITORED_QUESTION_LIST_PROVIDER_ID = "" +
+ "SurveyProgressController.monitored_question_list"
+private const val CURRENT_QUESTION_PROVIDER_ID =
+ "SurveyProgressController.current_question"
+private const val EPHEMERAL_QUESTION_FROM_UPDATED_QUESTION_LIST_PROVIDER_ID =
+ "SurveyProgressController.ephemeral_question_from_updated_question_list"
+private const val MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID =
+ "SurveyProgressController.move_to_next_question_result"
+private const val MOVE_TO_PREVIOUS_QUESTION_RESULT_PROVIDER_ID =
+ "SurveyProgressController.move_to_previous_question_result"
+private const val SUBMIT_ANSWER_RESULT_PROVIDER_ID =
+ "SurveyProgressController.submit_answer_result"
+private const val END_SESSION_RESULT_PROVIDER_ID = "SurveyProgressController.end_session_result"
+private const val RETRIEVE_RESPONSE_DATA_PROVIDER_ID =
+ "retrieve_response_provider_id"
+private const val AUGMENTED_QUESTION_PROVIDER_ID =
+ "SurveyProgressController.augmented_question"
+
+/**
+ * A default session ID to be used before a session has been initialized.
+ *
+ * This session ID will never match, so messages that are received with this ID will never be
+ * processed.
+ */
+private const val DEFAULT_SESSION_ID = "default_session_id"
+
+/** The name of the cache used to hold selected survey responses ephemerally. */
+private const val CACHE_NAME = "survey_responses_database"
+
+/** Controller for tracking the non-persisted progress of a survey. */
+@Singleton
+class SurveyProgressController @Inject constructor(
+ private val dataProviders: DataProviders,
+ private val exceptionsController: ExceptionsController,
+ @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher,
+ cacheStoreFactory: PersistentCacheStore.Factory,
+ private val surveyLogger: SurveyEventsLogger
+) {
+ // TODO(#606): Replace this with a profile scope.
+ private lateinit var profileId: ProfileId
+ private lateinit var surveyId: String
+
+ private var mostRecentSessionId: String? = null
+ private val activeSessionId: String
+ get() = mostRecentSessionId ?: DEFAULT_SESSION_ID
+
+ private var mostRecentEphemeralQuestionFlow =
+ createAsyncResultStateFlow(
+ AsyncResult.Failure(IllegalStateException("Survey is not yet initialized."))
+ )
+
+ private var mostRecentCommandQueue: SendChannel>? = null
+
+ private val monitoredQuestionListDataProvider: DataProviders.NestedTransformedDataProvider =
+ createCurrentQuestionDataProvider(createEmptyQuestionsListDataProvider())
+
+ private val answerDataStore =
+ cacheStoreFactory.create(CACHE_NAME, SelectedAnswerDatabase.getDefaultInstance())
+
+ /**
+ * Statuses correspond to the exceptions such that if the deferred contains an error state,
+ * a corresponding exception will be passed to a failed AsyncResult.
+ */
+ private enum class RecordResponseActionStatus {
+ /** Corresponds to a successful AsyncResult. */
+ SUCCESS,
+
+ /** Corresponds to a failed saving attempt. */
+ FAILED_TO_SAVE_RESPONSE
+ }
+
+ /**
+ * Begins a survey session based on a set of questions and returns a [DataProvider] indicating
+ * whether the start was successful.
+ */
+ fun beginSurveySession(
+ surveyId: String,
+ profileId: ProfileId,
+ questionsListDataProvider: DataProvider>
+ ): DataProvider {
+ val ephemeralQuestionFlow = createAsyncResultStateFlow()
+ val sessionId = UUID.randomUUID().toString().also {
+ mostRecentSessionId = it
+ mostRecentEphemeralQuestionFlow = ephemeralQuestionFlow
+ mostRecentCommandQueue = createControllerCommandActor()
+ }
+ monitoredQuestionListDataProvider.setBaseDataProvider(questionsListDataProvider) {
+ maybeSendReceiveQuestionListEvent(mostRecentCommandQueue, it)
+ }
+ val beginSessionResultFlow = createAsyncResultStateFlow()
+ val initializeMessage: ControllerMessage<*> =
+ ControllerMessage.InitializeController(
+ ephemeralQuestionFlow, sessionId, beginSessionResultFlow
+ )
+ this.profileId = profileId
+ this.surveyId = surveyId
+ sendCommandForOperation(initializeMessage) {
+ "Failed to schedule command for initializing the survey progress controller."
+ }
+ return beginSessionResultFlow.convertToSessionProvider(BEGIN_SESSION_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Returns a [DataProvider] monitoring the [EphemeralSurveyQuestion] the user is currently
+ * viewing.
+ *
+ * This [DataProvider] may switch from a completed to a pending result during transient operations
+ * like submitting an answer via [submitAnswer]. Calling code should be made resilient to this by
+ * caching the current question object to display since it may disappear temporarily during answer
+ * submission. Calling code should persist this object across configuration changes if
+ * needed since it cannot rely on this [DataProvider] for immediate UI reconstitution after
+ * configuration changes.
+ *
+ * The underlying question returned by this function can only be changed by calls to
+ * [moveToNextQuestion], or [moveToPreviousQuestion].
+ *
+ * This method does not need to be called for the [EphemeralSurveyQuestion] to be computed;
+ * it's always computed eagerly by other state-changing methods regardless of whether there's an
+ * active subscription to this method's returned [DataProvider].
+ */
+ fun getCurrentQuestion(): DataProvider {
+ val ephemeralQuestionDataProvider =
+ mostRecentEphemeralQuestionFlow.convertToSessionProvider(CURRENT_QUESTION_PROVIDER_ID)
+
+ // Combine ephemeral question with the monitored question list to ensure that changes to the
+ // questions list trigger a recompute of the ephemeral question.
+ val questionsListDataProvider = monitoredQuestionListDataProvider.combineWith(
+ ephemeralQuestionDataProvider, EPHEMERAL_QUESTION_FROM_UPDATED_QUESTION_LIST_PROVIDER_ID
+ ) { _, currentQuestion ->
+ currentQuestion
+ }
+ val previousAnswerProvider =
+ questionsListDataProvider.transformAsync(
+ RETRIEVE_RESPONSE_DATA_PROVIDER_ID
+ ) { ephemeralQuestion ->
+ return@transformAsync AsyncResult.Success(
+ retrieveSelectedAnswer(ephemeralQuestion.question.questionId.toString())
+ )
+ }
+ return previousAnswerProvider.combineWith(
+ questionsListDataProvider, AUGMENTED_QUESTION_PROVIDER_ID
+ ) { previousSelectedAnswer, ephemeralQuestion ->
+ return@combineWith if (previousSelectedAnswer != SurveySelectedAnswer.getDefaultInstance()) {
+ augmentEphemeralQuestion(previousSelectedAnswer, ephemeralQuestion)
+ } else ephemeralQuestion
+ }
+ }
+
+ /**
+ * Submits an answer to the current question and returns how the UI should respond.
+ *
+ * If the app undergoes a configuration change, calling code should rely on the [DataProvider]
+ * from [getCurrentQuestion] to know whether a current answer is pending. That [DataProvider] will
+ * have its state changed to pending during answer submission.
+ *
+ * No assumptions should be made about the completion order of the returned [DataProvider] vs. the
+ * [DataProvider] from [getCurrentQuestion].
+ */
+ fun submitAnswer(selectedAnswer: SurveySelectedAnswer): DataProvider {
+ val submitResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.SubmitAnswer(selectedAnswer, activeSessionId, submitResultFlow)
+ sendCommandForOperation(message) { "Failed to schedule command for answer submission." }
+ return submitResultFlow.convertToSessionProvider(SUBMIT_ANSWER_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Navigates to the next question in the survey. Calling code is responsible for ensuring this
+ * method is only called when it's possible to navigate forward.
+ *
+ * @return a [DataProvider] indicating whether the movement to the next question was successful,
+ * or a failure if question navigation was attempted at an invalid time (such as if the
+ * current question is pending or terminal). It's recommended that calling code only listen
+ * to this result for failures, and instead rely on [getCurrentQuestion] for observing a
+ * successful transition to another question.
+ */
+ fun moveToNextQuestion(): DataProvider {
+ val moveResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.MoveToNextQuestion(activeSessionId, moveResultFlow)
+ sendCommandForOperation(message) {
+ "Failed to schedule command for moving to the next question."
+ }
+ return moveResultFlow.convertToSessionProvider(MOVE_TO_NEXT_QUESTION_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Navigates to the previous question in the survey. If the user is currently on the initial
+ * question, this method will throw an exception. Calling code is responsible for ensuring this
+ * method is only called when it's possible to navigate backward.
+ *
+ * @return a [DataProvider] indicating whether the movement to the previous question was
+ * successful, or a failure if question navigation was attempted at an invalid time
+ * (such as if the user is viewing the first question in the survey). It's recommended that
+ * calling code only listen to this result for failures, and instead rely on
+ * [getCurrentQuestion] for observing a successful transition to another question.
+ */
+ fun moveToPreviousQuestion(): DataProvider {
+ val moveResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.MoveToPreviousQuestion(activeSessionId, moveResultFlow)
+ sendCommandForOperation(message) {
+ "Failed to schedule command for moving to the previous question."
+ }
+ return moveResultFlow.convertToSessionProvider(MOVE_TO_PREVIOUS_QUESTION_RESULT_PROVIDER_ID)
+ }
+
+ /**
+ * Ends the current survey session and returns a [DataProvider] that indicates whether it was
+ * successfully ended.
+ *
+ * This method must be called to explicitly notify the controller that the survey session is being
+ * stopped, in order to maybe save the responses.
+ *
+ * @param surveyCompleted whether this finish action indicates that the survey was fully completed by
+ * the user.
+ */
+ fun endSurveySession(
+ surveyCompleted: Boolean
+ ): DataProvider {
+ // Reset the base questions list provider so that the ephemeral question has no question list to
+ // reference (since the session finished).
+ monitoredQuestionListDataProvider.setBaseDataProvider(createEmptyQuestionsListDataProvider()) {
+ maybeSendReceiveQuestionListEvent(commandQueue = null, it)
+ }
+ val endSessionResultFlow = createAsyncResultStateFlow()
+ val message = ControllerMessage.FinishSurveySession(
+ surveyCompleted, activeSessionId, endSessionResultFlow
+ )
+ sendCommandForOperation(message) {
+ "Failed to schedule command for finishing the survey session."
+ }
+ return endSessionResultFlow.convertToSessionProvider(END_SESSION_RESULT_PROVIDER_ID)
+ }
+
+ private fun createCurrentQuestionDataProvider(
+ questionsListDataProvider: DataProvider>
+ ): DataProviders.NestedTransformedDataProvider {
+ return questionsListDataProvider.transformNested(MONITORED_QUESTION_LIST_PROVIDER_ID) {
+ maybeSendReceiveQuestionListEvent(commandQueue = null, it)
+ }
+ }
+
+ /** Returns a [DataProvider] that always provides an empty list of [SurveyQuestion]s. */
+ private fun createEmptyQuestionsListDataProvider(): DataProvider> {
+ return dataProviders.createInMemoryDataProvider(EMPTY_QUESTIONS_LIST_DATA_PROVIDER_ID) {
+ listOf()
+ }
+ }
+
+ private fun createControllerCommandActor(): SendChannel> {
+ lateinit var controllerState: ControllerState
+
+ @Suppress("JoinDeclarationAndAssignment") // Warning is incorrect in this case.
+ lateinit var commandQueue: SendChannel>
+ commandQueue = CoroutineScope(
+ backgroundCoroutineDispatcher
+ ).actor(capacity = Channel.UNLIMITED) {
+ for (message in channel) {
+ when (message) {
+ is ControllerMessage.InitializeController -> {
+ controllerState = ControllerState(
+ SurveyProgress(),
+ message.sessionId,
+ message.ephemeralQuestionFlow,
+ commandQueue
+ ).also {
+ it.beginSurveySessionImpl(message.callbackFlow)
+ }
+ }
+ is ControllerMessage.MoveToNextQuestion ->
+ controllerState.moveToNextQuestionImpl(message.callbackFlow)
+ is ControllerMessage.MoveToPreviousQuestion ->
+ controllerState.moveToPreviousQuestionImpl(message.callbackFlow)
+ is ControllerMessage.RecomputeQuestionAndNotify ->
+ controllerState.recomputeCurrentQuestionAndNotifyImpl()
+ is ControllerMessage.SubmitAnswer ->
+ controllerState.submitAnswerImpl(message.callbackFlow, message.selectedAnswer)
+ is ControllerMessage.ReceiveQuestionList ->
+ controllerState.handleUpdatedQuestionsList(message.questionsList)
+ is ControllerMessage.FinishSurveySession -> {
+ try {
+ controllerState.completeSurveyImpl(message.surveyCompleted, message.callbackFlow)
+ } finally {
+ // Ensure the actor ends since the session requires no further message processing.
+ break
+ }
+ }
+ }
+ }
+ }
+ return commandQueue
+ }
+
+ private fun sendCommandForOperation(
+ message: ControllerMessage,
+ lazyFailureMessage: () -> String
+ ) {
+ // TODO(#4119): Switch this to use trySend(), instead, which is much cleaner and doesn't require
+ // catching an exception.
+ val flowResult: AsyncResult = try {
+ val commandQueue = mostRecentCommandQueue
+ when {
+ commandQueue == null ->
+ AsyncResult.Failure(IllegalStateException("Session isn't initialized yet."))
+ !commandQueue.offer(message) ->
+ AsyncResult.Failure(IllegalStateException(lazyFailureMessage()))
+ // Ensure that the result is first reset since there will be a delay before the message is
+ // processed (if there's a flow).
+ else -> AsyncResult.Pending()
+ }
+ } catch (e: Exception) {
+ AsyncResult.Failure(e)
+ }
+ // This must be assigned separately since flowResult should always be calculated, even if
+ // there's no callbackFlow to report it.
+ message.callbackFlow?.value = flowResult
+ }
+
+ private suspend fun maybeSendReceiveQuestionListEvent(
+ commandQueue: SendChannel>?,
+ questionsList: List
+ ): AsyncResult {
+ // Only send the message if there's a queue to send it to (which there might not be for cases
+ // where a session isn't active).
+ commandQueue?.send(ControllerMessage.ReceiveQuestionList(questionsList, activeSessionId))
+ return AsyncResult.Success(null)
+ }
+
+ private suspend fun ControllerState.beginSurveySessionImpl(
+ beginSessionResultFlow: MutableStateFlow>
+ ) {
+ tryOperation(beginSessionResultFlow) {
+ recomputeCurrentQuestionAndNotifyAsync()
+ answerDataStore.clearCacheAsync()
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.LOADING_SURVEY_SESSION)
+ }
+ }
+
+ private suspend fun ControllerState.submitAnswerImpl(
+ submitAnswerResultFlow: MutableStateFlow>,
+ selectedAnswer: SurveySelectedAnswer
+ ) {
+ tryOperation(submitAnswerResultFlow) {
+ check(progress.surveyStage != SurveyProgress.SurveyStage.SUBMITTING_ANSWER) {
+ "Cannot submit an answer while another answer is pending."
+ }
+
+ val currentQuestionId = progress.questionDeck.getTopQuestionIndex()
+ if (selectedAnswer.questionName == SurveyQuestionName.NPS) {
+ // compute the feedback question before navigating to it
+ progress.questionGraph.computeFeedbackQuestion(
+ currentQuestionId + 1,
+ selectedAnswer.npsScore
+ )
+ }
+
+ saveSelectedAnswer(currentQuestionId.toString(), selectedAnswer)
+
+ if (!progress.questionDeck.isCurrentQuestionTerminal()) {
+ moveToNextQuestion()
+ }
+ }
+ }
+
+ private fun ControllerState.saveSelectedAnswer(questionId: String, answer: SurveySelectedAnswer) {
+ val deferred = recordSelectedAnswerAsync(questionId, answer)
+
+ deferred.invokeOnCompletion {
+ if (it == null) {
+ progress.questionDeck.trackAnsweredQuestions(answer.questionName)
+ deferred.getCompleted()
+ } else {
+ RecordResponseActionStatus.FAILED_TO_SAVE_RESPONSE
+ }
+ }
+ }
+
+ private fun recordSelectedAnswerAsync(
+ questionId: String,
+ answer: SurveySelectedAnswer
+ ): Deferred {
+ return answerDataStore.storeDataWithCustomChannelAsync(
+ updateInMemoryCache = true
+ ) { answerDatabase ->
+ answerDatabase.toBuilder().apply {
+ putSelectedAnswer(questionId, answer)
+ }.build() to RecordResponseActionStatus.SUCCESS
+ }
+ }
+
+ private suspend fun retrieveSelectedAnswer(questionId: String): SurveySelectedAnswer {
+ val answerDatabase = answerDataStore.readDataAsync().await()
+ return answerDatabase.selectedAnswerMap[questionId] ?: SurveySelectedAnswer.getDefaultInstance()
+ }
+
+ private suspend fun ControllerState.moveToNextQuestionImpl(
+ moveToNextQuestionResultFlow: MutableStateFlow>
+ ) {
+ tryOperation(moveToNextQuestionResultFlow) {
+ check(progress.surveyStage != SurveyProgress.SurveyStage.SUBMITTING_ANSWER) {
+ "Cannot navigate to a next question if an answer submission is pending."
+ }
+ progress.questionDeck.navigateToNextQuestion()
+ progress.refreshDeck()
+ }
+ }
+
+ private suspend fun ControllerState.moveToPreviousQuestionImpl(
+ moveToPreviousQuestionResultFlow: MutableStateFlow>
+ ) {
+ tryOperation(moveToPreviousQuestionResultFlow) {
+ check(progress.surveyStage != SurveyProgress.SurveyStage.LOADING_SURVEY_SESSION) {
+ "Cannot navigate to a previous question if a session is being loaded."
+ }
+ check(progress.surveyStage != SurveyProgress.SurveyStage.SUBMITTING_ANSWER) {
+ "Cannot navigate to a previous question if an answer submission is pending."
+ }
+ progress.questionDeck.navigateToPreviousQuestion()
+ progress.refreshDeck()
+ }
+ }
+
+ private suspend fun ControllerState.completeSurveyImpl(
+ surveyCompleted: Boolean,
+ endSessionResultFlow: MutableStateFlow>
+ ) {
+ checkNotNull(this) { "Cannot stop a survey session which wasn't started." }
+ tryOperation(endSessionResultFlow) {
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.NOT_IN_SURVEY_SESSION)
+ finishSurveyAndLog(surveyCompleted)
+ }
+ }
+
+ private fun createAsyncResultStateFlow(initialValue: AsyncResult = AsyncResult.Pending()) =
+ MutableStateFlow(initialValue)
+
+ private fun StateFlow>.convertToSessionProvider(
+ baseId: String
+ ): DataProvider = dataProviders.run {
+ convertAsyncToAutomaticDataProvider("${baseId}_$activeSessionId")
+ }
+
+ private suspend fun ControllerState.finishSurveyAndLog(surveyIsComplete: Boolean) {
+ when {
+ surveyIsComplete -> {
+ surveyLogger.logMandatoryResponses(
+ surveyId,
+ profileId,
+ getStoredResponse(SurveyQuestionName.USER_TYPE)!!,
+ getStoredResponse(SurveyQuestionName.MARKET_FIT)!!,
+ getStoredResponse(SurveyQuestionName.NPS)!!
+ )
+
+ // TODO(#5001): Log the optional question response to Firestore
+ }
+ progress.questionDeck.hasAnsweredAllMandatoryQuestions() -> {
+ surveyLogger.logMandatoryResponses(
+ surveyId,
+ profileId,
+ getStoredResponse(SurveyQuestionName.USER_TYPE)!!,
+ getStoredResponse(SurveyQuestionName.MARKET_FIT)!!,
+ getStoredResponse(SurveyQuestionName.NPS)!!
+ )
+ }
+ else -> {
+ val currentQuestionName = progress.questionGraph
+ .getQuestion(progress.questionDeck.getTopQuestionIndex())
+ .questionName
+
+ surveyLogger.logAbandonSurvey(
+ surveyId,
+ profileId,
+ currentQuestionName
+ )
+ }
+ }
+ answerDataStore.clearCacheAsync()
+ }
+
+ private suspend inline fun getStoredResponse(
+ questionName: SurveyQuestionName
+ ): T? {
+ val answerDatabase = answerDataStore.readDataAsync().await()
+ val savedAnswer =
+ answerDatabase.selectedAnswerMap.values.find { it.questionName == questionName }
+ return savedAnswer?.let { getAnswerTypeCase(it) }
+ }
+
+ private inline fun getAnswerTypeCase(surveyAnswer: SurveySelectedAnswer): T? {
+ return when {
+ surveyAnswer.userType != null && T::class == UserTypeAnswer::class ->
+ surveyAnswer.userType as? T
+ surveyAnswer.marketFit != null && T::class == MarketFitAnswer::class ->
+ surveyAnswer.marketFit as? T
+ surveyAnswer.npsScore != null && T::class == Int::class -> surveyAnswer.npsScore as? T
+ else -> null
+ }
+ }
+
+ /**
+ * Represents a message that can be sent to [mostRecentCommandQueue] to process changes to
+ * [ControllerState] (since all changes must be synchronized).
+ *
+ * Messages are expected to be resolved serially (though their scheduling can occur across
+ * multiple threads, so order cannot be guaranteed until they're enqueued).
+ */
+ private sealed class ControllerMessage {
+ /**
+ * The session ID corresponding to this message (the message is expected to be ignored if it
+ * doesn't correspond to an active session).
+ */
+ abstract val sessionId: String
+
+ /**
+ * The [DataProvider]-tied [MutableStateFlow] that represents the result of the operation
+ * corresponding to this message, or ``null`` if the caller doesn't care about observing the
+ * result.
+ */
+ abstract val callbackFlow: MutableStateFlow>?
+
+ /** [ControllerMessage] for initializing a new survey session. */
+ data class InitializeController(
+ val ephemeralQuestionFlow: MutableStateFlow>,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] for ending the current survey session. */
+ data class FinishSurveySession(
+ val surveyCompleted: Boolean,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] for submitting a new [SurveySelectedAnswer]. */
+ data class SubmitAnswer(
+ val selectedAnswer: SurveySelectedAnswer,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] to move to the previous question in the survey. */
+ data class MoveToPreviousQuestion(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /** [ControllerMessage] to move to the next question in the survey. */
+ data class MoveToNextQuestion(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>
+ ) : ControllerMessage()
+
+ /**
+ * [ControllerMessage] which recomputes the current [EphemeralSurveyQuestion] and notifies
+ * subscribers of the [DataProvider] returned by [getCurrentQuestion] of the change.
+ * This is only used in cases where an external operation trigger changes that are only
+ * reflected when recomputing the question (e.g. an answer was changed).
+ */
+ data class RecomputeQuestionAndNotify(
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>? = null
+ ) : ControllerMessage()
+
+ /**
+ * [ControllerMessage] for finishing the initialization of the survey session by providing a
+ * list of [SurveyQuestion]s to display.
+ */
+ data class ReceiveQuestionList(
+ val questionsList: List,
+ override val sessionId: String,
+ override val callbackFlow: MutableStateFlow>? = null
+ ) : ControllerMessage()
+ }
+
+ private suspend fun ControllerState.tryOperation(
+ resultFlow: MutableStateFlow>,
+ operation: suspend ControllerState.() -> T
+ ) {
+ try {
+ resultFlow.emit(AsyncResult.Success(operation()))
+ recomputeCurrentQuestionAndNotifySync()
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ resultFlow.emit(AsyncResult.Failure(e))
+ }
+ }
+
+ private suspend fun ControllerState.handleUpdatedQuestionsList(
+ questionsList: List
+ ) {
+ // The questions list is possibly changed which may affect the computed ephemeral question.
+ if (!this.isQuestionsListInitialized || this.questionsList != questionsList) {
+ this.questionsList = questionsList
+ // Only notify if the questions list is different (otherwise an infinite notify loop might be
+ // started).
+ recomputeCurrentQuestionAndNotifySync()
+ }
+ }
+
+ /**
+ * Immediately recomputes the current question & notifies it's been changed.
+ *
+ * This should only be called when the caller can guarantee that the current [ControllerState] is
+ * correct and up-to-date (i.e. that this is being called via a direct call path from the actor).
+ *
+ * All other cases must use [recomputeCurrentQuestionAndNotifyAsync].
+ */
+ private suspend fun ControllerState.recomputeCurrentQuestionAndNotifySync() {
+ recomputeCurrentQuestionAndNotifyImpl()
+ }
+
+ /**
+ * Sends a message to recompute the current question & notify it's been changed.
+ *
+ * This must be used in cases when the current [ControllerState] may no longer be up-to-date.
+ */
+ private suspend fun ControllerState.recomputeCurrentQuestionAndNotifyAsync() {
+ commandQueue.send(ControllerMessage.RecomputeQuestionAndNotify(sessionId))
+ }
+
+ private suspend fun ControllerState.recomputeCurrentQuestionAndNotifyImpl() {
+ ephemeralQuestionFlow.emit(
+ if (isQuestionsListInitialized) {
+ // Only compute the ephemeral question if there's a questions list loaded (otherwise the
+ // controller is in a pending state).
+ retrieveCurrentQuestionAsync(questionsList)
+ } else AsyncResult.Pending()
+ )
+ }
+
+ private suspend fun ControllerState.retrieveCurrentQuestionAsync(
+ questionsList: List
+ ): AsyncResult {
+ return try {
+ when (progress.surveyStage) {
+ SurveyProgress.SurveyStage.NOT_IN_SURVEY_SESSION -> AsyncResult.Pending()
+ SurveyProgress.SurveyStage.LOADING_SURVEY_SESSION -> {
+ // If the survey hasn't yet been initialized, initialize it
+ // now that a list of questions is available.
+ initializeSurvey(questionsList)
+ progress.advancePlayStageTo(SurveyProgress.SurveyStage.VIEWING_SURVEY_QUESTION)
+ AsyncResult.Success(computeBaseCurrentEphemeralQuestion())
+ }
+ SurveyProgress.SurveyStage.VIEWING_SURVEY_QUESTION -> {
+ AsyncResult.Success(computeBaseCurrentEphemeralQuestion())
+ }
+ SurveyProgress.SurveyStage.SUBMITTING_ANSWER -> AsyncResult.Pending()
+ }
+ } catch (e: Exception) {
+ exceptionsController.logNonFatalException(e)
+ AsyncResult.Failure(e)
+ }
+ }
+
+ private fun ControllerState.initializeSurvey(questionsList: List) {
+ check(questionsList.isNotEmpty()) { "Cannot start a survey session with zero questions." }
+ progress.initialize(questionsList)
+ }
+
+ private fun ControllerState.computeBaseCurrentEphemeralQuestion(): EphemeralSurveyQuestion =
+ progress.questionDeck.getCurrentEphemeralQuestion()
+
+ /**
+ * Augments the specified [EphemeralSurveyQuestion] [AsyncResult] by attaching a previously
+ * selected answer to update the UI.
+ */
+ private fun augmentEphemeralQuestion(
+ previousAnswer: SurveySelectedAnswer,
+ ephemeralQuestion: EphemeralSurveyQuestion
+ ): EphemeralSurveyQuestion {
+ return ephemeralQuestion.toBuilder().apply {
+ selectedAnswer = previousAnswer
+ }.build()
+ }
+
+ /**
+ * Represents the current synchronized state of the controller.
+ *
+ * This object's instance is tied directly to a single training session, and it's not thread-safe
+ * so all access must be synchronized.
+ *
+ * @property progress the [SurveyProgress] corresponding to the session
+ * @property sessionId the GUID corresponding to the session
+ * @property ephemeralQuestionFlow the [MutableStateFlow] that the updated
+ * [EphemeralSurveyQuestion] is delivered to.
+ * @property commandQueue the actor command queue executing all messages that change this state
+ */
+ private class ControllerState(
+ val progress: SurveyProgress,
+ val sessionId: String,
+ val ephemeralQuestionFlow: MutableStateFlow>,
+ val commandQueue: SendChannel>
+ ) {
+ /**
+ * The list of [SurveyQuestion]s currently being played in the training session.
+ *
+ * Because this is updated based on [ControllerMessage.ReceiveQuestionList], it may not be
+ * initialized at the beginning of a session. Callers should check [isQuestionsListInitialized]
+ * prior to accessing this field.
+ */
+ lateinit var questionsList: List
+
+ /** Indicates whether [questionsList] is initialized with values. */
+ val isQuestionsListInitialized: Boolean
+ get() = ::questionsList.isInitialized
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
new file mode 100644
index 00000000000..5add7977b2d
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt
@@ -0,0 +1,112 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+
+/**
+ * Tracks the dynamic behavior of the user through a survey session. This class
+ * treats the survey progress like a deck of cards to simplify forward/backward navigation.
+ */
+class SurveyQuestionDeck constructor(
+ private val totalQuestionCount: Int,
+ initialQuestion: SurveyQuestion,
+ private val isTopOfDeckTerminalChecker: (SurveyQuestion) -> Boolean
+) {
+ private var pendingTopQuestion = initialQuestion
+ private var viewedQuestionsCount: Int = 0
+ private var questionIndex: Int = 0
+ private val answeredQuestions = mutableListOf()
+
+ /** Sets this deck to a specific question. */
+ fun updateDeck(pendingTopQuestion: SurveyQuestion) {
+ this.pendingTopQuestion = pendingTopQuestion
+ }
+
+ /** Navigates to the previous question in the deck or fails if it is not possible. */
+ fun navigateToPreviousQuestion() {
+ check(!isCurrentQuestionInitial()) {
+ "Cannot navigate to previous question; at initial question."
+ }
+ questionIndex--
+ }
+
+ /** Navigates to the next question in the deck or fails if it is not possible. */
+ fun navigateToNextQuestion() {
+ check(!isCurrentQuestionTerminal()) {
+ "Cannot navigate to next question; at terminal question."
+ }
+ questionIndex++
+ viewedQuestionsCount++
+ }
+
+ /** Returns the index of the current selected question of the deck. */
+ fun getTopQuestionIndex(): Int = questionIndex
+
+ /** Returns whether this is the first question in the survey. */
+ private fun isCurrentQuestionInitial(): Boolean {
+ return questionIndex == 0
+ }
+
+ /** Returns the current [EphemeralSurveyQuestion] the learner is viewing. */
+ fun getCurrentEphemeralQuestion(): EphemeralSurveyQuestion {
+ return if (isCurrentQuestionTerminal()) {
+ getCurrentTerminalQuestion()
+ } else {
+ getCurrentPendingQuestion()
+ }
+ }
+
+ private fun getCurrentPendingQuestion(): EphemeralSurveyQuestion {
+ return EphemeralSurveyQuestion.newBuilder()
+ .setHasPreviousQuestion(!isCurrentQuestionInitial())
+ .setHasNextQuestion(!isCurrentQuestionTerminal())
+ .setQuestion(pendingTopQuestion)
+ .setPendingQuestion(true)
+ .setCurrentQuestionIndex(questionIndex)
+ .setTotalQuestionCount(totalQuestionCount)
+ .build()
+ }
+
+ private fun getCurrentTerminalQuestion(): EphemeralSurveyQuestion {
+ return EphemeralSurveyQuestion.newBuilder()
+ .setHasPreviousQuestion(!isCurrentQuestionInitial())
+ .setHasNextQuestion(false)
+ .setQuestion(pendingTopQuestion)
+ .setTerminalQuestion(true)
+ .setCurrentQuestionIndex(questionIndex)
+ .setTotalQuestionCount(totalQuestionCount)
+ .build()
+ }
+
+ /** Returns whether this is the most recent question in the survey. */
+ fun isCurrentQuestionTopOfDeck(): Boolean {
+ return questionIndex == viewedQuestionsCount
+ }
+
+ /** Returns whether this is the last question in the survey. */
+ fun isCurrentQuestionTerminal(): Boolean {
+ return isCurrentQuestionTopOfDeck() && isTopOfDeckTerminal()
+ }
+
+ /** Returns whether the most recent card on the deck is terminal. */
+ private fun isTopOfDeckTerminal(): Boolean {
+ return isTopOfDeckTerminalChecker(pendingTopQuestion)
+ }
+
+ /** Stores a list of all the questions that have been answered in the survey. */
+ fun trackAnsweredQuestions(questionName: SurveyQuestionName) {
+ answeredQuestions.add(questionName)
+ }
+
+ /**
+ * Returns whether the user has answered all the mandatory questions, which indicate partial
+ * survey completion.
+ *
+ * The user must have answered the [SurveyQuestionName.NPS] question for the survey to be
+ * considered partially completed.
+ */
+ fun hasAnsweredAllMandatoryQuestions(): Boolean {
+ return answeredQuestions.contains(SurveyQuestionName.NPS)
+ }
+}
diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt
new file mode 100644
index 00000000000..727b30a7c14
--- /dev/null
+++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt
@@ -0,0 +1,27 @@
+package org.oppia.android.domain.survey
+
+import org.oppia.android.app.model.SurveyQuestion
+import org.oppia.android.app.model.SurveyQuestionName
+
+/** Computes the next question in the deck and provides lookup access for [SurveyQuestion]s. */
+class SurveyQuestionGraph constructor(
+ private var questionList: MutableList
+) {
+ /** Returns the [SurveyQuestion] corresponding to the provided index. */
+ fun getQuestion(questionIndex: Int): SurveyQuestion = questionList[questionIndex]
+
+ /** Decides which feedback question should be shown based on a user's nps score selection. */
+ fun computeFeedbackQuestion(index: Int, npsScore: Int) {
+ when (npsScore) {
+ in 9..10 -> questionList[index] = createQuestion(SurveyQuestionName.PROMOTER_FEEDBACK)
+ in 7..8 -> questionList[index] = createQuestion(SurveyQuestionName.PASSIVE_FEEDBACK)
+ else -> questionList[index] = createQuestion(SurveyQuestionName.DETRACTOR_FEEDBACK)
+ }
+ }
+
+ private fun createQuestion(questionName: SurveyQuestionName): SurveyQuestion {
+ return SurveyQuestion.newBuilder()
+ .setQuestionName(questionName)
+ .build()
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt
index ba7c412f482..9aac3e477ac 100644
--- a/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt
@@ -873,7 +873,7 @@ class AudioPlayerControllerTest {
DragDropSortInputModule::class, ImageClickInputModule::class, RatioInputModule::class,
NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
MathEquationInputModule::class, CachingTestModule::class, HintsAndSolutionProdModule::class,
- HintsAndSolutionConfigModule::class, LoggerModule::class, ExplorationProgressModule::class
+ HintsAndSolutionConfigModule::class, LoggerModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt
index 7d1b9d25988..3a7e5176c60 100644
--- a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt
@@ -1000,7 +1000,7 @@ class ExplorationCheckpointControllerTest {
AlgebraicExpressionInputModule::class, MathEquationInputModule::class,
RatioInputModule::class, ImageClickInputModule::class, InteractionsModule::class,
HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
- ExplorationProgressModule::class
+ ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel
index 85124495fd0..7014081371a 100644
--- a/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel
@@ -36,4 +36,35 @@ oppia_android_test(
],
)
+oppia_android_test(
+ name = "DeprecationControllerTest",
+ srcs = ["DeprecationControllerTest.kt"],
+ custom_package = "org.oppia.android.domain.onboarding",
+ test_class = "org.oppia.android.domain.onboarding.DeprecationControllerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain",
+ "//domain/src/main/java/org/oppia/android/domain/onboarding:deprecation_controller",
+ "//domain/src/main/java/org/oppia/android/domain/onboarding:retriever_prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor",
+ "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner",
+ "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_robolectric_test_runner",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_mockito_mockito-core",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ "//utility/src/main/java/org/oppia/android/util/system:prod_module",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt
new file mode 100644
index 00000000000..3ef5714961d
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt
@@ -0,0 +1,279 @@
+package org.oppia.android.domain.onboarding
+
+import android.app.Application
+import android.content.Context
+import android.os.Bundle
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.core.content.pm.ApplicationInfoBuilder
+import androidx.test.core.content.pm.PackageInfoBuilder
+import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.BuildFlavor
+import org.oppia.android.app.model.DeprecationNoticeType
+import org.oppia.android.app.model.DeprecationResponse
+import org.oppia.android.app.model.DeprecationResponseDatabase
+import org.oppia.android.data.persistence.PersistentCacheStore
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.domain.platformparameter.PlatformParameterModule
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.junit.OppiaParameterizedTestRunner
+import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.SelectRunnerPlatform
+import org.oppia.android.testing.junit.ParameterizedRobolectricTestRunner
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.system.OppiaClockModule
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [DeprecationController]. */
+// FunctionName: test names are conventionally named with underscores.
+@Suppress("FunctionName")
+@RunWith(OppiaParameterizedTestRunner::class)
+@SelectRunnerPlatform(ParameterizedRobolectricTestRunner::class)
+@Config(application = DeprecationControllerTest.TestApplication::class)
+class DeprecationControllerTest {
+ @Inject lateinit var context: Context
+ @Inject lateinit var deprecationController: DeprecationController
+ @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+ @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory
+
+ @Test
+ fun testController_providesInitialState_indicatesNoUpdatesReceivedFromGatingConsole() {
+ val defaultDeprecationResponseDatabase = DeprecationResponseDatabase
+ .getDefaultInstance()
+
+ setUpDefaultTestApplicationComponent()
+
+ val deprecationDataProvider = deprecationController
+ .getDeprecationDatabase()
+
+ val deprecationResponseDatabase = monitorFactory
+ .waitForNextSuccessfulResult(deprecationDataProvider)
+
+ assertThat(deprecationResponseDatabase.osDeprecationResponse)
+ .isEqualTo(defaultDeprecationResponseDatabase.osDeprecationResponse)
+
+ assertThat(deprecationResponseDatabase.appDeprecationResponse)
+ .isEqualTo(defaultDeprecationResponseDatabase.appDeprecationResponse)
+ }
+
+ @Test
+ fun testController_observedAfterSettingAppDeprecation_providesUpdatedDeprecationResponse() {
+ executeInPreviousAppInstance { testComponent ->
+ val appDeprecationResponse = DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.APP_DEPRECATION
+ }.build()
+
+ testComponent.getDeprecationController().saveDeprecationResponse(appDeprecationResponse)
+ testComponent.getTestCoroutineDispatchers().runCurrent()
+ }
+
+ // Create the application after previous arrangement to simulate a re-creation.
+ setUpDefaultTestApplicationComponent()
+
+ val deprecationDataProvider = deprecationController
+ .getDeprecationDatabase()
+
+ val deprecationResponseDatabase = monitorFactory
+ .waitForNextSuccessfulResult(deprecationDataProvider)
+
+ assertThat(deprecationResponseDatabase.appDeprecationResponse)
+ .isEqualTo(
+ DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.APP_DEPRECATION
+ }.build()
+ )
+ }
+
+ @Test
+ fun testController_observedAfterSettingOsDeprecation_providesUpdatedDeprecationResponse() {
+ executeInPreviousAppInstance { testComponent ->
+ val osDeprecationResponse = DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.OS_DEPRECATION
+ }.build()
+
+ testComponent.getDeprecationController().saveDeprecationResponse(osDeprecationResponse)
+ testComponent.getTestCoroutineDispatchers().runCurrent()
+ }
+
+ // Create the application after previous arrangement to simulate a re-creation.
+ setUpDefaultTestApplicationComponent()
+
+ val deprecationDataProvider = deprecationController
+ .getDeprecationDatabase()
+
+ val deprecationResponseDatabase = monitorFactory
+ .waitForNextSuccessfulResult(deprecationDataProvider)
+
+ assertThat(deprecationResponseDatabase.osDeprecationResponse)
+ .isEqualTo(
+ DeprecationResponse.newBuilder().apply {
+ deprecatedVersion = 5
+ deprecationNoticeType = DeprecationNoticeType.OS_DEPRECATION
+ }.build()
+ )
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext().inject(this)
+ }
+
+ private fun setUpOppiaApplication(expirationEnabled: Boolean, expDate: String) {
+ setUpOppiaApplicationForContext(context, expirationEnabled, expDate)
+ }
+
+ /**
+ * Creates a separate test application component and executes the specified block. This should be
+ * called before [setUpTestApplicationComponent] to avoid undefined behavior in production code.
+ * This can be used to simulate arranging state in a "prior" run of the app.
+ *
+ * Note that only dependencies fetched from the specified [TestApplicationComponent] should be
+ * used, not any class-level injected dependencies.
+ */
+ private fun executeInPreviousAppInstance(block: (TestApplicationComponent) -> Unit) {
+ val testApplication = TestApplication()
+ // The true application is hooked as a base context. This is to make sure the new application
+ // can behave like a real Android application class (per Robolectric) without having a shared
+ // Dagger dependency graph with the application under test.
+ testApplication.attachBaseContext(ApplicationProvider.getApplicationContext())
+ block(
+ DaggerDeprecationControllerTest_TestApplicationComponent.builder()
+ .setApplication(testApplication)
+ .build()
+ )
+ }
+
+ private fun setUpOppiaApplicationForContext(
+ context: Context,
+ expirationEnabled: Boolean,
+ expDate: String
+ ) {
+ val packageManager = Shadows.shadowOf(context.packageManager)
+ val applicationInfo =
+ ApplicationInfoBuilder.newBuilder()
+ .setPackageName(context.packageName)
+ .setName("Oppia")
+ .build()
+ applicationInfo.metaData = Bundle()
+ applicationInfo.metaData.putBoolean("automatic_app_expiration_enabled", expirationEnabled)
+ applicationInfo.metaData.putString("expiration_date", expDate)
+ val packageInfo =
+ PackageInfoBuilder.newBuilder()
+ .setPackageName(context.packageName)
+ .setApplicationInfo(applicationInfo)
+ .build()
+ packageManager.installPackage(packageInfo)
+ }
+
+ private fun setUpDefaultTestApplicationComponent() {
+ setUpTestApplicationComponent()
+
+ // By default, set up the application to never expire.
+ setUpOppiaApplication(expirationEnabled = false, expDate = "9999-12-31")
+ }
+
+ @Module
+ class TestModule {
+ companion object {
+ var buildFlavor = BuildFlavor.BUILD_FLAVOR_UNSPECIFIED
+ }
+
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ // module in tests to avoid needing to specify these settings for tests.
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+
+ @Provides
+ fun provideTestingBuildFlavor(): BuildFlavor = buildFlavor
+ }
+
+ @Singleton
+ @Component(
+ modules = [
+ LogStorageModule::class, RobolectricModule::class,
+ TestModule::class, TestDispatcherModule::class, TestLogReportingModule::class,
+ NetworkConnectionUtilDebugModule::class,
+ OppiaClockModule::class, LocaleProdModule::class,
+ ExpirationMetaDataRetrieverModule::class, // Use real implementation to test closer to prod.
+ LoggingIdentifierModule::class, ApplicationLifecycleModule::class,
+ SyncStatusModule::class, PlatformParameterModule::class,
+ PlatformParameterSingletonModule::class
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun getDeprecationController(): DeprecationController
+
+ fun getCacheFactory(): PersistentCacheStore.Factory
+
+ fun getTestCoroutineDispatchers(): TestCoroutineDispatchers
+
+ fun getContext(): Context
+
+ fun inject(deprecationControllerTest: DeprecationControllerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerDeprecationControllerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(deprecationControllerTest: DeprecationControllerTest) {
+ component.inject(deprecationControllerTest)
+ }
+
+ public override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt
index 077ac2cc14c..bf940cfe8e1 100644
--- a/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/OppiaLoggerTest.kt
@@ -13,6 +13,7 @@ import dagger.Provides
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.BEGIN_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.CLOSE_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY
@@ -25,11 +26,11 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_QUE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_REVISION_TAB
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_STORY_ACTIVITY
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SHOW_SURVEY_POPUP
import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
import org.oppia.android.testing.FakeAnalyticsEventLogger
import org.oppia.android.testing.TestLogReportingModule
-import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat
import org.oppia.android.testing.robolectric.RobolectricModule
import org.oppia.android.testing.threading.TestDispatcherModule
import org.oppia.android.testing.time.FakeOppiaClock
@@ -99,9 +100,14 @@ class OppiaLoggerTest {
private val TEST_ERROR_EXCEPTION = Throwable(TEST_ERROR_LOG_EXCEPTION)
}
- @Inject lateinit var oppiaLogger: OppiaLogger
- @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
- @Inject lateinit var fakeOppiaClock: FakeOppiaClock
+ @Inject
+ lateinit var oppiaLogger: OppiaLogger
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var fakeOppiaClock: FakeOppiaClock
@Before
fun setUp() {
@@ -315,6 +321,24 @@ class OppiaLoggerTest {
assertThat(eventContext.closeRevisionCard.subTopicId).isEqualTo(TEST_SUB_TOPIC_ID)
}
+ @Test
+ fun testController_createShowSurveyPopupContext_returnsCorrectShowSurveyPopupContext() {
+ val eventContext = oppiaLogger.createShowSurveyPopupContext(TEST_EXPLORATION_ID, TEST_TOPIC_ID)
+
+ assertThat(eventContext.activityContextCase).isEqualTo(SHOW_SURVEY_POPUP)
+ assertThat(eventContext.showSurveyPopup.topicId).matches(TEST_TOPIC_ID)
+ assertThat(eventContext.showSurveyPopup.explorationId).isEqualTo(TEST_EXPLORATION_ID)
+ }
+
+ @Test
+ fun testController_createBeginSurveyContext_returnsCorrectBeginSurveyContext() {
+ val eventContext = oppiaLogger.createBeginSurveyContext(TEST_EXPLORATION_ID, TEST_TOPIC_ID)
+
+ assertThat(eventContext.activityContextCase).isEqualTo(BEGIN_SURVEY)
+ assertThat(eventContext.beginSurvey.topicId).matches(TEST_TOPIC_ID)
+ assertThat(eventContext.beginSurvey.explorationId).isEqualTo(TEST_EXPLORATION_ID)
+ }
+
private fun setUpTestApplicationComponent() {
DaggerOppiaLoggerTest_TestApplicationComponent.builder()
.setApplication(ApplicationProvider.getApplicationContext())
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel
index 346231f3738..12886f46df6 100644
--- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel
@@ -259,4 +259,34 @@ oppia_android_test(
],
)
+oppia_android_test(
+ name = "SurveyEventsLoggerTest",
+ srcs = ["SurveyEventsLoggerTest.kt"],
+ custom_package = "org.oppia.android.domain.oppialogger.analytics",
+ test_class = "org.oppia.android.domain.oppialogger.analytics.SurveyEventsLoggerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/survey:survey_events_logger",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor",
+ "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject",
+ "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module",
+ "//testing/src/main/java/org/oppia/android/testing/platformparameter:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/time:test_module",
+ "//third_party:androidx_test_ext_junit",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt
index 76c90666235..462dd3f04ad 100644
--- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt
@@ -89,16 +89,25 @@ class LearnerAnalyticsLoggerTest {
private const val DEFAULT_INITIAL_SESSION_ID = "e6eacc69-e636-3c90-ba29-32bf3dd17161"
}
- @Inject lateinit var learnerAnalyticsLogger: LearnerAnalyticsLogger
- @Inject lateinit var explorationDataController: ExplorationDataController
- @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory
- @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
- @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
-
- @Parameter lateinit var iid: String
- @Parameter lateinit var lid: String
- @Parameter lateinit var eid: String
- @Parameter lateinit var elid: String
+ @Inject
+ lateinit var learnerAnalyticsLogger: LearnerAnalyticsLogger
+ @Inject
+ lateinit var explorationDataController: ExplorationDataController
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Parameter
+ lateinit var iid: String
+ @Parameter
+ lateinit var lid: String
+ @Parameter
+ lateinit var eid: String
+ @Parameter
+ lateinit var elid: String
private val learnerIdParameter: String? get() = lid.takeIf { it != "null" }
private val installIdParameter: String? get() = iid.takeIf { it != "null" }
@@ -1828,7 +1837,7 @@ class LearnerAnalyticsLoggerTest {
NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class,
MathEquationInputModule::class, ImageClickInputModule::class, AssetModule::class,
HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class,
- CachingTestModule::class, ExplorationProgressModule::class
+ CachingTestModule::class, ExplorationProgressModule::class,
]
)
interface TestApplicationComponent : DataProvidersInjector {
diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt
new file mode 100644
index 00000000000..4a5e3723f33
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/SurveyEventsLoggerTest.kt
@@ -0,0 +1,191 @@
+package org.oppia.android.domain.oppialogger.analytics
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.domain.oppialogger.EventLogStorageCacheSize
+import org.oppia.android.domain.oppialogger.ExceptionLogStorageCacheSize
+import org.oppia.android.domain.oppialogger.LoggingIdentifierModule
+import org.oppia.android.domain.oppialogger.survey.SurveyEventsLogger
+import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule
+import org.oppia.android.testing.FakeAnalyticsEventLogger
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat
+import org.oppia.android.testing.logging.SyncStatusTestModule
+import org.oppia.android.testing.platformparameter.TestPlatformParameterModule
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyEventsLogger]. */
+// FunctionName: test names are conventionally named with underscores.
+@Suppress("FunctionName")
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = SurveyEventsLoggerTest.TestApplication::class)
+class SurveyEventsLoggerTest {
+ private companion object {
+ private const val TEST_SURVEY_ID = "test_survey_id"
+ }
+
+ @Inject
+ lateinit var surveyEventsLogger: SurveyEventsLogger
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ private val profileId by lazy { ProfileId.newBuilder().apply { internalId = 0 }.build() }
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testLogAbandonSurvey_logsEventWithCorrectValues() {
+ surveyEventsLogger.logAbandonSurvey(TEST_SURVEY_ID, profileId, SurveyQuestionName.MARKET_FIT)
+ testCoroutineDispatchers.runCurrent()
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+
+ assertThat(eventLog).hasAbandonSurveyContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isEqualTo(TEST_SURVEY_ID)
+ hasInternalProfileIdThat().isEqualTo("0")
+ }
+ hasQuestionNameThat().isEqualTo(SurveyQuestionName.MARKET_FIT)
+ }
+ }
+
+ @Test
+ fun testLogMandatoryResponses_logsEventWithCorrectValues() {
+ surveyEventsLogger.logMandatoryResponses(
+ TEST_SURVEY_ID,
+ profileId,
+ UserTypeAnswer.LEARNER,
+ MarketFitAnswer.DISAPPOINTED,
+ npsScore = 8
+ )
+ testCoroutineDispatchers.runCurrent()
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+
+ assertThat(eventLog).hasMandatorySurveyResponseContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("0")
+ }
+ hasUserTypeAnswerThat().isEqualTo(UserTypeAnswer.LEARNER)
+ hasMarketFitAnswerThat().isEqualTo(MarketFitAnswer.DISAPPOINTED)
+ hasNpsScoreAnswerThat().isEqualTo(8)
+ }
+ }
+
+ private fun setUpTestApplicationComponent() {
+ DaggerSurveyEventsLoggerTest_TestApplicationComponent.builder()
+ .setApplication(ApplicationProvider.getApplicationContext())
+ .build()
+ .inject(this)
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Module
+ class TestModule {
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ // module in tests to avoid needing to specify these settings for tests.
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+ }
+
+ @Module
+ class TestLogStorageModule {
+ @Provides
+ @EventLogStorageCacheSize
+ fun provideEventLogStorageCacheSize(): Int = 2
+
+ @Provides
+ @ExceptionLogStorageCacheSize
+ fun provideExceptionLogStorageCacheSize(): Int = 2
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, TestLogReportingModule::class, RobolectricModule::class,
+ TestDispatcherModule::class, TestLogStorageModule::class,
+ NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, FakeOppiaClockModule::class,
+ TestPlatformParameterModule::class, PlatformParameterSingletonModule::class,
+ LoggingIdentifierModule::class, SyncStatusTestModule::class,
+ ApplicationLifecycleModule::class, AssetModule::class
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(test: SurveyEventsLoggerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyEventsLoggerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(test: SurveyEventsLoggerTest) {
+ component.inject(test)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
index 5e8abd91fde..d2d4394ce79 100644
--- a/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
+++ b/domain/src/test/java/org/oppia/android/domain/survey/BUILD.bazel
@@ -33,4 +33,59 @@ oppia_android_test(
],
)
+oppia_android_test(
+ name = "SurveyControllerTest",
+ srcs = ["SurveyControllerTest.kt"],
+ custom_package = "org.oppia.android.domain.survey",
+ test_class = "org.oppia.android.domain.survey.SurveyControllerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/time:test_module",
+ "//third_party:androidx_test_ext_junit",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ ],
+)
+
+oppia_android_test(
+ name = "SurveyProgressControllerTest",
+ srcs = ["SurveyProgressControllerTest.kt"],
+ custom_package = "org.oppia.android.domain.survey",
+ test_class = "org.oppia.android.domain.survey.SurveyProgressControllerTest",
+ test_manifest = "//domain:test_manifest",
+ deps = [
+ ":dagger",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module",
+ "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller",
+ "//testing",
+ "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject",
+ "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/threading:test_module",
+ "//testing/src/main/java/org/oppia/android/testing/time:test_module",
+ "//third_party:androidx_test_ext_junit",
+ "//third_party:com_google_truth_truth",
+ "//third_party:junit_junit",
+ "//third_party:org_robolectric_robolectric",
+ "//third_party:robolectric_android-all",
+ "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module",
+ "//utility/src/main/java/org/oppia/android/util/locale:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/logging:prod_module",
+ "//utility/src/main/java/org/oppia/android/util/networking:debug_module",
+ ],
+)
+
dagger_rules()
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
new file mode 100644
index 00000000000..eb9e43ab83a
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt
@@ -0,0 +1,242 @@
+package org.oppia.android.domain.survey
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.oppialogger.ApplicationIdSeed
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.testing.FakeExceptionLogger
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics
+import org.oppia.android.util.platformparameter.PlatformParameterValue
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyController]. */
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = SurveyControllerTest.TestApplication::class)
+class SurveyControllerTest {
+ @Inject
+ lateinit var fakeExceptionLogger: FakeExceptionLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+
+ @Inject
+ lateinit var surveyController: SurveyController
+
+ @Inject
+ lateinit var surveyProgressController: SurveyProgressController
+
+ private val questions = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ private val profileId = ProfileId.newBuilder().setInternalId(1).build()
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testController_startSurveySession_succeeds() {
+ val surveyDataProvider =
+ surveyController.startSurveySession(questions, profileId = profileId)
+
+ monitorFactory.waitForNextSuccessfulResult(surveyDataProvider)
+ }
+
+ @Test
+ fun testController_startSurveySession_sessionStartsWithInitialQuestion() {
+ surveyController.startSurveySession(questions, profileId = profileId)
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.question.questionName).isEqualTo(SurveyQuestionName.USER_TYPE)
+ }
+
+ @Test
+ fun testStartSurveySession_withTwoQuestions_showOptionalQuestion_succeeds() {
+ val mandatoryQuestionNameList = listOf(SurveyQuestionName.NPS)
+ surveyController.startSurveySession(mandatoryQuestionNameList, profileId = profileId)
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(2)
+ }
+
+ @Test
+ fun testStartSurveySession_withTwoQuestions_dontShowOptionalQuestion_succeeds() {
+ val mandatoryQuestionNameList = listOf(SurveyQuestionName.MARKET_FIT, SurveyQuestionName.NPS)
+ surveyController.startSurveySession(
+ mandatoryQuestionNames = mandatoryQuestionNameList,
+ showOptionalQuestion = false,
+ profileId = profileId
+ )
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(2)
+ }
+
+ @Test
+ fun testStartSurveySession_withOneQuestion_showOptionalQuestionOnly_succeeds() {
+ val mandatoryQuestionNameList = listOf()
+ surveyController.startSurveySession(
+ mandatoryQuestionNames = mandatoryQuestionNameList,
+ showOptionalQuestion = true,
+ profileId = profileId
+ )
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(1)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.PROMOTER_FEEDBACK)
+ }
+
+ @Test
+ fun testStartSurveySession_withOneQuestion_mandatoryQuestionOnly_succeeds() {
+ val mandatoryQuestionNameList = listOf(SurveyQuestionName.NPS)
+ surveyController.startSurveySession(
+ mandatoryQuestionNames = mandatoryQuestionNameList,
+ showOptionalQuestion = false,
+ profileId = profileId
+ )
+
+ val result = surveyProgressController.getCurrentQuestion()
+ val ephemeralQuestion = monitorFactory.waitForNextSuccessfulResult(result)
+ assertThat(ephemeralQuestion.totalQuestionCount).isEqualTo(1)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.NPS)
+ }
+
+ @Test
+ fun testStopSurveySession_withoutStartingSession_returnsFailure() {
+ val stopProvider = surveyController.stopSurveySession(true)
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(stopProvider)
+
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext()
+ .inject(this)
+ }
+
+ @Module
+ class TestModule {
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+
+ @Provides
+ @EnableLearnerStudyAnalytics
+ fun provideLearnerStudyAnalytics(): PlatformParameterValue {
+ // Enable the study by default in tests.
+ return PlatformParameterValue.createDefaultParameter(defaultValue = true)
+ }
+ }
+
+ @Module
+ class TestLoggingIdentifierModule {
+ companion object {
+ const val applicationIdSeed = 1L
+ }
+
+ @Provides
+ @ApplicationIdSeed
+ fun provideApplicationIdSeed(): Long = applicationIdSeed
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
+ ApplicationLifecycleModule::class, TestDispatcherModule::class, LocaleProdModule::class,
+ ExplorationProgressModule::class, TestLogReportingModule::class, AssetModule::class,
+ NetworkConnectionUtilDebugModule::class, SyncStatusModule::class, LogStorageModule::class,
+ TestLoggingIdentifierModule::class,
+ ]
+ )
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(surveyControllerTest: SurveyControllerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyControllerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(surveyControllerTest: SurveyControllerTest) {
+ component.inject(surveyControllerTest)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+}
diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
new file mode 100644
index 00000000000..865120fb089
--- /dev/null
+++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt
@@ -0,0 +1,583 @@
+package org.oppia.android.domain.survey
+
+import android.app.Application
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.oppia.android.app.model.EphemeralSurveyQuestion
+import org.oppia.android.app.model.MarketFitAnswer
+import org.oppia.android.app.model.ProfileId
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.SurveySelectedAnswer
+import org.oppia.android.app.model.UserTypeAnswer
+import org.oppia.android.domain.exploration.ExplorationProgressModule
+import org.oppia.android.domain.oppialogger.ApplicationIdSeed
+import org.oppia.android.domain.oppialogger.LogStorageModule
+import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule
+import org.oppia.android.testing.FakeAnalyticsEventLogger
+import org.oppia.android.testing.FakeExceptionLogger
+import org.oppia.android.testing.TestLogReportingModule
+import org.oppia.android.testing.data.DataProviderTestMonitor
+import org.oppia.android.testing.logging.EventLogSubject
+import org.oppia.android.testing.robolectric.RobolectricModule
+import org.oppia.android.testing.threading.TestCoroutineDispatchers
+import org.oppia.android.testing.threading.TestDispatcherModule
+import org.oppia.android.testing.time.FakeOppiaClockModule
+import org.oppia.android.util.caching.AssetModule
+import org.oppia.android.util.data.DataProvidersInjector
+import org.oppia.android.util.data.DataProvidersInjectorProvider
+import org.oppia.android.util.locale.LocaleProdModule
+import org.oppia.android.util.logging.EnableConsoleLog
+import org.oppia.android.util.logging.EnableFileLog
+import org.oppia.android.util.logging.GlobalLogLevel
+import org.oppia.android.util.logging.LogLevel
+import org.oppia.android.util.logging.SyncStatusModule
+import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule
+import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics
+import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+import org.oppia.android.util.platformparameter.PlatformParameterValue
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.LooperMode
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Tests for [SurveyProgressController]. */
+@RunWith(AndroidJUnit4::class)
+@LooperMode(LooperMode.Mode.PAUSED)
+@Config(application = SurveyProgressControllerTest.TestApplication::class)
+class SurveyProgressControllerTest {
+ @Inject
+ lateinit var fakeExceptionLogger: FakeExceptionLogger
+
+ @Inject
+ lateinit var testCoroutineDispatchers: TestCoroutineDispatchers
+
+ @Inject
+ lateinit var monitorFactory: DataProviderTestMonitor.Factory
+
+ @Inject
+ lateinit var surveyController: SurveyController
+
+ @Inject
+ lateinit var surveyProgressController: SurveyProgressController
+
+ @Inject
+ lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger
+
+ private val profileId = ProfileId.newBuilder().setInternalId(1).build()
+
+ @Before
+ fun setUp() {
+ setUpTestApplicationComponent()
+ }
+
+ @Test
+ fun testStartSurveySession_succeeds() {
+ val surveyDataProvider =
+ surveyController.startSurveySession(questions, profileId = profileId)
+
+ monitorFactory.waitForNextSuccessfulResult(surveyDataProvider)
+ }
+
+ @Test
+ fun testStartSurveySession_sessionStartsWithInitialQuestion() {
+ startSuccessfulSurveySession()
+
+ val ephemeralQuestion = waitForGetCurrentQuestionSuccessfulLoad()
+ assertThat(ephemeralQuestion.question.questionName).isEqualTo(SurveyQuestionName.USER_TYPE)
+ }
+
+ @Test
+ fun testGetCurrentQuestion_sessionLoaded_returnsInitialQuestionPending() {
+ startSuccessfulSurveySession()
+
+ val ephemeralQuestion = waitForGetCurrentQuestionSuccessfulLoad()
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(0)
+ assertThat(ephemeralQuestion.totalQuestionCount).isGreaterThan(0)
+ assertThat(ephemeralQuestion.question.questionName).isEqualTo(SurveyQuestionName.USER_TYPE)
+ assertThat(ephemeralQuestion.hasPreviousQuestion).isEqualTo(false)
+ assertThat(ephemeralQuestion.hasNextQuestion).isEqualTo(true)
+ assertThat(ephemeralQuestion.questionTypeCase)
+ .isEqualTo(EphemeralSurveyQuestion.QuestionTypeCase.PENDING_QUESTION)
+ }
+
+ @Test
+ fun testGetCurrentQuestion_fourthQuestion_isTerminalQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val currentQuestion = submitNpsAnswer(7)
+
+ assertThat(currentQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(currentQuestion.totalQuestionCount).isEqualTo(4)
+ assertThat(currentQuestion.questionTypeCase)
+ .isEqualTo(EphemeralSurveyQuestion.QuestionTypeCase.TERMINAL_QUESTION)
+ }
+
+ @Test
+ fun testGetCurrentQuestion_noSessionStarted_throwsException() {
+ // Can't retrieve the current question until the survey session is started.
+ val getCurrentQuestionProvider = surveyProgressController.getCurrentQuestion()
+
+ val result = monitorFactory.waitForNextFailureResult(getCurrentQuestionProvider)
+ assertThat(result).hasCauseThat().hasMessageThat().contains("Survey is not yet initialized.")
+ }
+
+ @Test
+ fun testSubmitAnswer_beforeStartingSurvey_isFailure() {
+ val submitAnswerProvider =
+ surveyProgressController.submitAnswer(createUserTypeAnswer(UserTypeAnswer.LEARNER))
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(submitAnswerProvider)
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ @Test
+ fun testSubmitAnswer_forUserTypeQuestion_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+
+ val result = surveyProgressController.submitAnswer(createUserTypeAnswer(UserTypeAnswer.PARENT))
+
+ // Verify that the answer submission was successful.
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testSubmitAnswer_forMarketFitQuestion_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+
+ val result =
+ surveyProgressController.submitAnswer(
+ createMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ )
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testSubmitAnswer_forNpsScoreQuestion_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val result =
+ surveyProgressController.submitAnswer(createNpsAnswer(9))
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testSubmitAnswer_forTextInput_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ submitNpsAnswer(7)
+
+ val result =
+ surveyProgressController.submitAnswer(
+ createTextInputAnswer(
+ SurveyQuestionName.PASSIVE_FEEDBACK,
+ TEXT_ANSWER
+ )
+ )
+
+ monitorFactory.waitForNextSuccessfulResult(result)
+ }
+
+ @Test
+ fun testMoveToNext_beforePlaying_isFailure() {
+ val moveToNextProvider = surveyProgressController.moveToNextQuestion()
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(moveToNextProvider)
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ @Test
+ fun testMoveToNext_onTerminalQuestion_failsWithError() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ submitNpsAnswer(7)
+ submitTextInputAnswer(SurveyQuestionName.PASSIVE_FEEDBACK, TEXT_ANSWER)
+
+ val moveToNextProvider = surveyProgressController.moveToNextQuestion()
+
+ val error = monitorFactory.waitForNextFailureResult(moveToNextProvider)
+
+ assertThat(error)
+ .hasMessageThat()
+ .contains("Cannot navigate to next question; at terminal question.")
+ }
+
+ @Test
+ fun testSubmitAnswer_submitNpsScore0f3_loadsDetractorFeedbackQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.NOT_DISAPPOINTED)
+
+ val ephemeralQuestion = submitNpsAnswer(3)
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.DETRACTOR_FEEDBACK)
+ }
+
+ @Test
+ fun testSubmitAnswer_submitNpsScore0f7_loadsPassiveFeedbackQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.DISAPPOINTED)
+
+ val ephemeralQuestion = submitNpsAnswer(7)
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.PASSIVE_FEEDBACK)
+ }
+
+ @Test
+ fun testSubmitAnswer_submitNpsScore0f10_loadsPromoterFeedbackQuestion() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val ephemeralQuestion = submitNpsAnswer(10)
+
+ assertThat(ephemeralQuestion.currentQuestionIndex).isEqualTo(3)
+ assertThat(ephemeralQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.PROMOTER_FEEDBACK)
+ }
+
+ @Test
+ fun testMoveToPreviousQuestion_atInitialQuestion_isFailure() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+
+ val moveTolPreviousProvider = surveyProgressController.moveToPreviousQuestion()
+ val result = monitorFactory.waitForNextFailureResult(moveTolPreviousProvider)
+
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat()
+ .contains("Cannot navigate to previous question; at initial question.")
+ }
+
+ @Test
+ fun testMoveToPreviousQuestion_afterMovingToNextQuestion_isSuccess() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.LEARNER)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ val currentQuestion = moveToPreviousQuestion()
+
+ assertThat(currentQuestion.currentQuestionIndex).isEqualTo(1)
+ assertThat(currentQuestion.question.questionName)
+ .isEqualTo(SurveyQuestionName.MARKET_FIT)
+ }
+
+ @Test
+ fun testSubmitAnswer_afterMovingToPreviousQuestion_isSuccess() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.LEARNER)
+ // Submit answer and move to next
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+
+ moveToPreviousQuestion()
+
+ // Submit a different answer to the navigated question
+ val submitAnswerProvider =
+ surveyProgressController.submitAnswer(createMarketFitAnswer(MarketFitAnswer.NOT_DISAPPOINTED))
+
+ // New answer is submitted successfully
+ monitorFactory.waitForNextSuccessfulResult(submitAnswerProvider)
+ }
+
+ @Test
+ fun testStopSurveySession_withoutStartingSession_returnsFailure() {
+ val stopProvider = surveyController.stopSurveySession(surveyCompleted = true)
+
+ // The operation should be failing since the session hasn't started.
+ val result = monitorFactory.waitForNextFailureResult(stopProvider)
+
+ assertThat(result).isInstanceOf(IllegalStateException::class.java)
+ assertThat(result).hasMessageThat().contains("Session isn't initialized yet.")
+ }
+
+ @Test
+ fun testStopSurveySession_afterStartingPreviousSession_succeeds() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ val stopProvider = surveyController.stopSurveySession(surveyCompleted = false)
+ monitorFactory.waitForNextSuccessfulResult(stopProvider)
+ }
+
+ @Test
+ fun testEndSurvey_beforeCompletingMandatoryQuestions_logsAbandonSurveyEvent() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ // Submit and navigate to NPS question
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ stopSurveySession(surveyCompleted = false)
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(eventLog).hasAbandonSurveyContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("1")
+ }
+ hasQuestionNameThat().isEqualTo(SurveyQuestionName.NPS)
+ }
+ }
+
+ @Test
+ fun testEndSurvey_afterCompletingMandatoryQuestions_logsMandatorySurveyResponseEvent() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ // Submit and navigate to FEEDBACK question
+ submitNpsAnswer(10)
+ stopSurveySession(surveyCompleted = false)
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(eventLog).hasMandatorySurveyResponseContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("1")
+ }
+ hasUserTypeAnswerThat().isEqualTo(UserTypeAnswer.PARENT)
+ hasMarketFitAnswerThat().isEqualTo(MarketFitAnswer.VERY_DISAPPOINTED)
+ hasNpsScoreAnswerThat().isEqualTo(10)
+ }
+ }
+
+ @Test
+ fun testEndSurvey_afterCompletingAllQuestions_logsMandatorySurveyResponseEvent() {
+ startSuccessfulSurveySession()
+ waitForGetCurrentQuestionSuccessfulLoad()
+ submitUserTypeAnswer(UserTypeAnswer.PARENT)
+ submitMarketFitAnswer(MarketFitAnswer.VERY_DISAPPOINTED)
+ submitNpsAnswer(10)
+ submitTextInputAnswer(SurveyQuestionName.PROMOTER_FEEDBACK, TEXT_ANSWER)
+ stopSurveySession(surveyCompleted = true)
+
+ val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent()
+ EventLogSubject.assertThat(eventLog).hasMandatorySurveyResponseContextThat {
+ hasSurveyDetailsThat {
+ hasSurveyIdThat().isNotEmpty()
+ hasInternalProfileIdThat().isEqualTo("1")
+ }
+ hasUserTypeAnswerThat().isEqualTo(UserTypeAnswer.PARENT)
+ hasMarketFitAnswerThat().isEqualTo(MarketFitAnswer.VERY_DISAPPOINTED)
+ hasNpsScoreAnswerThat().isEqualTo(10)
+ }
+ }
+
+ // TODO(#5001): Add tests for Optional responses logging to Firestore
+
+ private fun stopSurveySession(surveyCompleted: Boolean) {
+ val stopProvider = surveyController.stopSurveySession(surveyCompleted)
+ monitorFactory.waitForNextSuccessfulResult(stopProvider)
+ }
+
+ private fun startSuccessfulSurveySession() {
+ monitorFactory.waitForNextSuccessfulResult(
+ surveyController.startSurveySession(questions, profileId = profileId)
+ )
+ }
+
+ private fun waitForGetCurrentQuestionSuccessfulLoad(): EphemeralSurveyQuestion {
+ return monitorFactory.waitForNextSuccessfulResult(
+ surveyProgressController.getCurrentQuestion()
+ )
+ }
+
+ private fun moveToPreviousQuestion(): EphemeralSurveyQuestion {
+ // This operation might fail for some tests.
+ monitorFactory.ensureDataProviderExecutes(
+ surveyProgressController.moveToPreviousQuestion()
+ )
+ return waitForGetCurrentQuestionSuccessfulLoad()
+ }
+
+ private fun submitAnswer(answer: SurveySelectedAnswer): EphemeralSurveyQuestion {
+ monitorFactory.waitForNextSuccessfulResult(
+ surveyProgressController.submitAnswer(answer)
+ )
+ return waitForGetCurrentQuestionSuccessfulLoad()
+ }
+
+ private fun submitUserTypeAnswer(answer: UserTypeAnswer): EphemeralSurveyQuestion {
+ return submitAnswer(createUserTypeAnswer(answer))
+ }
+
+ private fun createUserTypeAnswer(
+ answer: UserTypeAnswer
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.USER_TYPE)
+ .setUserType(answer)
+ .build()
+ }
+
+ private fun submitMarketFitAnswer(answer: MarketFitAnswer): EphemeralSurveyQuestion {
+ return submitAnswer(createMarketFitAnswer(answer))
+ }
+
+ private fun createMarketFitAnswer(
+ answer: MarketFitAnswer
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.MARKET_FIT)
+ .setMarketFit(answer)
+ .build()
+ }
+
+ private fun submitNpsAnswer(answer: Int): EphemeralSurveyQuestion {
+ return submitAnswer(createNpsAnswer(answer))
+ }
+
+ private fun createNpsAnswer(
+ answer: Int
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(SurveyQuestionName.NPS)
+ .setNpsScore(answer)
+ .build()
+ }
+
+ private fun submitTextInputAnswer(
+ questionName: SurveyQuestionName,
+ textAnswer: String
+ ): EphemeralSurveyQuestion = submitAnswer(createTextInputAnswer(questionName, textAnswer))
+
+ private fun createTextInputAnswer(
+ questionName: SurveyQuestionName,
+ textAnswer: String
+ ): SurveySelectedAnswer {
+ return SurveySelectedAnswer.newBuilder()
+ .setQuestionName(questionName)
+ .setFreeFormAnswer(textAnswer)
+ .build()
+ }
+
+ private fun setUpTestApplicationComponent() {
+ ApplicationProvider.getApplicationContext()
+ .inject(this)
+ }
+
+ @Module
+ class TestModule {
+ internal companion object {
+ var enableLearnerStudyAnalytics = LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE
+ }
+
+ @Provides
+ @Singleton
+ fun provideContext(application: Application): Context {
+ return application
+ }
+
+ // TODO(#59): Either isolate these to their own shared test module, or use the real logging
+ @EnableConsoleLog
+ @Provides
+ fun provideEnableConsoleLog(): Boolean = true
+
+ @EnableFileLog
+ @Provides
+ fun provideEnableFileLog(): Boolean = false
+
+ @GlobalLogLevel
+ @Provides
+ fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE
+
+ @Provides
+ @EnableLearnerStudyAnalytics
+ fun provideLearnerStudyAnalytics(): PlatformParameterValue {
+ // Enable the study by default in tests.
+ return PlatformParameterValue.createDefaultParameter(defaultValue = true)
+ }
+ }
+
+ @Module
+ class TestLoggingIdentifierModule {
+ companion object {
+ const val applicationIdSeed = 1L
+ }
+
+ @Provides
+ @ApplicationIdSeed
+ fun provideApplicationIdSeed(): Long = applicationIdSeed
+ }
+
+ // TODO(#89): Move this to a common test application component.
+ @Singleton
+ @Component(
+ modules = [
+ TestModule::class, RobolectricModule::class, FakeOppiaClockModule::class,
+ ApplicationLifecycleModule::class, TestDispatcherModule::class, LocaleProdModule::class,
+ ExplorationProgressModule::class, TestLogReportingModule::class, AssetModule::class,
+ NetworkConnectionUtilDebugModule::class, SyncStatusModule::class, LogStorageModule::class,
+ TestLoggingIdentifierModule::class
+ ]
+ )
+
+ interface TestApplicationComponent : DataProvidersInjector {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun inject(surveyProgressControllerTest: SurveyProgressControllerTest)
+ }
+
+ class TestApplication : Application(), DataProvidersInjectorProvider {
+ private val component: TestApplicationComponent by lazy {
+ DaggerSurveyProgressControllerTest_TestApplicationComponent.builder()
+ .setApplication(this)
+ .build()
+ }
+
+ fun inject(surveyProgressControllerTest: SurveyProgressControllerTest) {
+ component.inject(surveyProgressControllerTest)
+ }
+
+ override fun getDataProvidersInjector(): DataProvidersInjector = component
+ }
+
+ companion object {
+ private const val TEXT_ANSWER = "Some text answer"
+ private val questions = listOf(
+ SurveyQuestionName.USER_TYPE,
+ SurveyQuestionName.MARKET_FIT,
+ SurveyQuestionName.NPS
+ )
+ }
+}
diff --git a/model/src/main/proto/BUILD.bazel b/model/src/main/proto/BUILD.bazel
index cc7e9fa399c..a168e981191 100644
--- a/model/src/main/proto/BUILD.bazel
+++ b/model/src/main/proto/BUILD.bazel
@@ -48,6 +48,7 @@ oppia_proto_library(
":exploration_proto",
":languages_proto",
":profile_proto",
+ ":survey_proto",
],
)
@@ -153,6 +154,18 @@ java_lite_proto_library(
deps = [":onboarding_proto"],
)
+oppia_proto_library(
+ name = "deprecation_proto",
+ srcs = ["deprecation.proto"],
+ deps = [":version_proto"],
+)
+
+java_lite_proto_library(
+ name = "deprecation_java_proto_lite",
+ visibility = ["//:oppia_api_visibility"],
+ deps = [":deprecation_proto"],
+)
+
oppia_proto_library(
name = "spotlight_proto",
srcs = ["spotlight.proto"],
@@ -337,6 +350,7 @@ oppia_proto_library(
name = "survey_proto",
srcs = ["survey.proto"],
visibility = ["//:oppia_api_visibility"],
+ deps = [":languages_proto"],
)
java_lite_proto_library(
diff --git a/model/src/main/proto/arguments.proto b/model/src/main/proto/arguments.proto
index c8e7119366f..35b7278d833 100644
--- a/model/src/main/proto/arguments.proto
+++ b/model/src/main/proto/arguments.proto
@@ -303,3 +303,14 @@ message AppLanguageActivityStateBundle {
// The default app language selected by the user.
OppiaLanguage oppia_language = 1;
}
+
+message SurveyActivityParams {
+ // The ID of the profile for which the survey is to be shown.
+ ProfileId profile_id = 1;
+
+ // The ID of the topic to which the triggering exploration belongs.
+ string topic_id = 2;
+
+ // The ID of the triggering exploration.
+ string exploration_id = 3;
+}
diff --git a/model/src/main/proto/deprecation.proto b/model/src/main/proto/deprecation.proto
new file mode 100644
index 00000000000..8e63e84901c
--- /dev/null
+++ b/model/src/main/proto/deprecation.proto
@@ -0,0 +1,45 @@
+syntax = "proto3";
+
+package model;
+
+option java_package = "org.oppia.android.app.model";
+option java_multiple_files = true;
+
+// Top-level proto used to store deprecation responses, which correspond to a user's interaction
+// with a dialog notifying them of either an optional update or of an OS deprecation.
+message DeprecationResponseDatabase {
+ // Stores a user's response to a dialog notifying the user that an optional update is available
+ // for download.
+ DeprecationResponse app_deprecation_response = 1;
+
+ // Stores a user's response to a dialog notifying the user that their OS is deprecated.
+ DeprecationResponse os_deprecation_response = 2;
+}
+
+// Represents a response to a dialog notifying the user that an optional update is available for
+// download, or that their OS is deprecated.
+message DeprecationResponse {
+ // The app version of the latest available update at the time the user dismissed a deprecation
+ // notice dialog.
+ int32 deprecated_version = 1;
+
+ // The timestamp in milliseconds since epoch corresponding to the date and time that the user
+ // dismissed the deprecation notice dialog.
+ uint64 notice_dismissed_timestamp_millis = 2;
+
+ // The [DeprecationNoticeType] of the dialog that the user has dismissed. This can
+ // either be unspecified, an app deprecation or an OS deprecation notice.
+ DeprecationNoticeType deprecation_notice_type = 3;
+}
+
+// An enum object that represents the different deprecation notices that can be shown to the user.
+enum DeprecationNoticeType {
+ // Unspecified notice type.
+ DEPRECATION_NOTICE_TYPE_UNSPECIFIED = 0;
+
+ // App update notice type.
+ APP_DEPRECATION = 1;
+
+ // OS deprecation notice type.
+ OS_DEPRECATION = 2;
+}
diff --git a/model/src/main/proto/onboarding.proto b/model/src/main/proto/onboarding.proto
index 3ed97a65062..4cefc9213d7 100644
--- a/model/src/main/proto/onboarding.proto
+++ b/model/src/main/proto/onboarding.proto
@@ -24,6 +24,15 @@ message AppStartupState {
// continue using it. Instead, they should be shown a prompt suggesting that they update the app
// via the Play Store.
APP_IS_DEPRECATED = 3;
+
+ // Indicates that a new app version is available and the user should be shown a prompt to update
+ // the app. Since the update is optional, the user can choose to update or not.
+ OPTIONAL_UPDATE_AVAILABLE = 4;
+
+ // Indicates that a new app version is available but the user can not update the app because
+ // they are using an OS version that is no longer supported. The user should be shown a prompt
+ // to update their OS.
+ OS_IS_DEPRECATED = 5;
}
// Describes different notices that may be shown to the user on startup depending on whether
diff --git a/model/src/main/proto/oppia_logger.proto b/model/src/main/proto/oppia_logger.proto
index 708961a86c9..e2d6cc455ea 100644
--- a/model/src/main/proto/oppia_logger.proto
+++ b/model/src/main/proto/oppia_logger.proto
@@ -4,6 +4,7 @@ package model;
import "languages.proto";
import "profile.proto";
+import "survey.proto";
option java_package = "org.oppia.android.app.model";
option java_multiple_files = true;
@@ -158,6 +159,22 @@ message EventLog {
// Indicates that something went wrong when trying to log a learner analytics even for the
// device corresponding to the specified device ID.
string install_id_for_failed_analytics_log = 33;
+
+ // The event being logged is related to viewing a survey popup dialog.
+ SurveyContext show_survey_popup = 38;
+
+ // The event being logged is related to a survey session being started.
+ SurveyContext begin_survey = 39;
+
+ // The event being logged is related to a survey session being started and ended before the
+ // mandatory questions are completed.
+ AbandonSurveyContext abandon_survey = 40;
+
+ // The event being logged is related to the responses to the mandatory survey questions.
+ MandatorySurveyResponseContext mandatory_response = 41;
+
+ // The event being logged is related to the response to the optional survey question.
+ OptionalSurveyResponseContext optional_response = 42;
}
}
@@ -299,6 +316,58 @@ message EventLog {
OppiaLanguage switch_to_language = 3;
}
+ // Structure of a survey context.
+ message SurveyContext {
+ // The active topic ID when the event is logged.
+ string topic_id = 1;
+
+ // The active exploration ID when the event is logged.
+ string exploration_id = 2;
+ }
+
+ // Represents the event context for when a survey is exited without completing all the mandatory
+ // questions.
+ message AbandonSurveyContext {
+ // Defined attributes that are common among other survey related event log contexts.
+ SurveyResponseContext survey_details = 1;
+
+ // The semantic name of the question at which the survey was abandoned.
+ SurveyQuestionName question_name = 2;
+ }
+
+ // Represents the event context that contains the responses to the mandatory survey questions.
+ message MandatorySurveyResponseContext {
+ // Defined attributes that are common among other survey related event log contexts.
+ SurveyResponseContext survey_details = 1;
+
+ // The semantic name of the selected answer for the user type question.
+ UserTypeAnswer user_type_answer = 2;
+
+ // The semantic name of the selected answer for the market fit question.
+ MarketFitAnswer market_fit_answer = 3;
+
+ // The integer value representing the score selected by the user for the NPS question.
+ int32 nps_score_answer = 4;
+ }
+
+ // Represents the event context that contains the response to the optional survey question.
+ message OptionalSurveyResponseContext {
+ // Defined attributes that are common among other survey related event log contexts.
+ SurveyResponseContext survey_details = 1;
+
+ // The string value representing the free form answer given for the feedback question.
+ string feedback_answer = 2;
+ }
+
+ // Structure of a survey response context.
+ message SurveyResponseContext {
+ // The ID of the survey for which this response event is being logged.
+ string survey_id = 1;
+
+ // The ID of the Oppia profile currently logged in, responding to the survey.
+ string profile_id = 2;
+ }
+
// Supported priority of events for event logging
enum Priority {
// The undefined priority of an event
diff --git a/model/src/main/proto/profile.proto b/model/src/main/proto/profile.proto
index 9d0ccb026ff..aadd1f34881 100644
--- a/model/src/main/proto/profile.proto
+++ b/model/src/main/proto/profile.proto
@@ -2,8 +2,6 @@ syntax = "proto3";
package model;
-import "languages.proto";
-
option java_package = "org.oppia.android.app.model";
option java_multiple_files = true;
diff --git a/model/src/main/proto/screens.proto b/model/src/main/proto/screens.proto
index dae4c8b99be..e0ee3599d6d 100644
--- a/model/src/main/proto/screens.proto
+++ b/model/src/main/proto/screens.proto
@@ -155,6 +155,9 @@ enum ScreenName {
// A generic foreground screen value for logging periodic metrics like CPU usage.
FOREGROUND_SCREEN = 48;
+
+ // Screen name value for the scenario when the survey activity is visible to the user.
+ SURVEY_ACTIVITY = 49;
}
// Defines the current visible UI screen of the application.
diff --git a/model/src/main/proto/survey.proto b/model/src/main/proto/survey.proto
index 86d1c52a4da..c32481a02af 100644
--- a/model/src/main/proto/survey.proto
+++ b/model/src/main/proto/survey.proto
@@ -14,8 +14,11 @@ message Survey {
// This field is used to retrieve information about a survey, and should be treated as read-only.
string survey_id = 1;
- // A list of questions that make up the survey.
- repeated SurveyQuestion questions = 2;
+ // A list of questions which if not answered in full, the survey is considered incomplete.
+ repeated SurveyQuestion mandatory_questions = 2;
+
+ // An optional question that allows the user to provide additional feedback during the survey.
+ SurveyQuestion optional_question = 3;
}
// Represents a question that is part of the Survey.
@@ -56,7 +59,7 @@ enum SurveyQuestionName {
// Corresponds to the name of the user type question.
USER_TYPE = 1;
- // Corresponds to the market fit question.
+ // Corresponds to the market fit question.
MARKET_FIT = 2;
// Corresponds to the NPS question.
@@ -103,16 +106,23 @@ message SurveySelectedAnswer {
// Semantic name of the question the response is tied to.
SurveyQuestionName question_name = 2;
+ // The ID of the survey question. This is a string value that uniquely identifies the question
+ // within the context of the survey, and is guaranteed to be unique within the survey scope only.
+ string question_id = 3;
+
// The value of the answer the user gave.
oneof answer {
// The enum representation of the type of user answer selected.
- UserTypeAnswer user_type = 3;
+ UserTypeAnswer user_type = 4;
// The enum representation of the market fit answer selected.
- MarketFitAnswer market_fit = 4;
+ MarketFitAnswer market_fit = 5;
// The integer value representing the nps score selected by the user.
- int32 nps_score = 5;
+ int32 nps_score = 6;
+
+ // The string value representing the free text answer entered by a user.
+ string free_form_answer = 7;
}
}
@@ -122,8 +132,8 @@ enum UserTypeAnswer {
// User type unknown.
USER_TYPE_UNSPECIFIED = 0;
- // Corresponds to an unspecified or unknown type of user.
- OTHER = 1;
+ // Corresponds to a user who is a learner.
+ LEARNER = 1;
// Corresponds to a user who is a teacher.
TEACHER = 2;
@@ -131,8 +141,8 @@ enum UserTypeAnswer {
// Corresponds to a user who is a parent.
PARENT = 3;
- // Corresponds to a user who is a learner.
- LEARNER = 4;
+ // Corresponds to an unspecified or unknown type of user.
+ OTHER = 4;
}
// Represents general opinion of the user about the Oppia android app.
@@ -191,10 +201,26 @@ message EphemeralSurveyQuestion {
// A pending question that requires an answer to continue.
bool pending_question = 4;
- // The SurveySelectedAnswer given to complete the previous question.
+ // The SurveySelectedAnswer given to complete this question.
SurveySelectedAnswer selected_answer = 5;
// Present if this is the last question in the survey.
bool terminal_question = 6;
}
+
+ // Corresponds the index of the current question in the survey, starting at 0. This index is
+ // guaranteed to be unique for a specific question and will be equal to the total question count if
+ // the learner has reached the end of the survey.
+ int32 current_question_index = 7;
+
+ // Corresponds to the number of questions in the survey. This value will never change in the same
+ // survey instance.
+ int32 total_question_count = 8;
+}
+
+// Top-level proto used to store the user selected answers in a survey, and is expected to only
+// exist ephemerally in memory during the survey session.
+message SelectedAnswerDatabase {
+ // Map from question ID to SurveySelectedAnswer for that question.
+ map selected_answer = 1;
}
diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto
index 47fb27575e8..2ffc743232d 100644
--- a/scripts/assets/file_content_validation_checks.textproto
+++ b/scripts/assets/file_content_validation_checks.textproto
@@ -331,6 +331,7 @@ file_content_checks {
exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/PolynomialExtensionsTest.kt"
exempted_file_name: "utility/src/test/java/org/oppia/android/util/math/RealExtensionsTest.kt"
exempted_file_name: "utility/src/test/java/org/oppia/android/util/profile/ProfileNameValidatorTest.kt"
+ exempted_file_name: "domain/src/test/java/org/oppia/android/domain/onboarding/DeprecationControllerTest.kt"
exempted_file_patterns: "testing/src/main/java/org/oppia/android/testing/junit/.+?\\.kt"
}
file_content_checks {
@@ -352,17 +353,6 @@ file_content_checks {
prohibited_content_regex: "@color/(?!component_color_).+|\"#\\p{XDigit}+\""
failure_message: "Only colors from component_colors.xml may be used in layouts."
}
-file_content_checks {
- file_path_regex: "app/src/main/res/drawable.*?/.+?\\.xml"
- prohibited_content_regex: "@color/(?!component_color_).+|\"#\\p{XDigit}+\""
- failure_message: "Only colors from component_colors.xml may be used in drawables except vector assets."
- exempted_file_patterns: "app/src/main/res/drawable.*?/(ic_|lesson_thumbnail_graphic_).+?\\.xml"
- exempted_file_patterns: "app/src/main/res/drawable/full_oppia_logo.xml"
- exempted_file_patterns: "app/src/main/res/drawable/rounded_white_background_with_shadow.xml"
- exempted_file_patterns: "app/src/main/res/drawable/profile_image_shadow.xml"
- exempted_file_patterns: "app/src/main/res/drawable/selected_region_background.xml"
- exempted_file_patterns: "app/src/main/res/drawable/splash_page.xml"
-}
file_content_checks {
file_path_regex: "app/src/main/java/org/oppia/android/app.?/.+(ActivityPresenter|FragmentPresenter|ViewPresenter|Activity|Fragment|View)\\.kt"
prohibited_content_regex: "R.color.(?!component_color_).+"
diff --git a/scripts/assets/kdoc_validity_exemptions.textproto b/scripts/assets/kdoc_validity_exemptions.textproto
index eb5f23309f8..8b2c0be260b 100644
--- a/scripts/assets/kdoc_validity_exemptions.textproto
+++ b/scripts/assets/kdoc_validity_exemptions.textproto
@@ -3,6 +3,7 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/application/Applica
exempted_file_path: "app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/application/ApplicationStartupListenerModule.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/administratorcontrols/RouteToProfileListListener.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/notice/AutomaticAppDeprecationNoticeDialogFragmentPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivityPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsFragment.kt"
@@ -183,6 +184,19 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryFragment
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivityPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/BindableAdapterTestActivity.kt"
@@ -285,6 +299,7 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/oppialogger/l
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionAssessmentProgress.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionConstantsProvider.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/QuestionTrainingConstantsProvider.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/StoryProgressController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt"
diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto
index e4cfb2dade9..699f8b9a9cd 100644
--- a/scripts/assets/test_file_exemptions.textproto
+++ b/scripts/assets/test_file_exemptions.textproto
@@ -71,6 +71,7 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/ChapterN
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/ContinueButtonView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt"
@@ -221,6 +222,7 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/home/recentlyplayed
exempted_file_path: "app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivityPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/home/recentlyplayed/SectionTitleViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/home/topiclist/AllTopicsViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryClickListener.kt"
@@ -434,6 +436,24 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/story/StoryViewMode
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt"
+exempted_file_path: "app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AppCompatCheckBoxBindingAdaptersTestActivity.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt"
exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivityPresenter.kt"
@@ -694,6 +714,10 @@ exempted_file_path: "domain/src/main/java/org/oppia/android/domain/question/Ques
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateGraph.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/state/StateList.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyConstantsProvider.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt"
+exempted_file_path: "domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/testing/oppialogger/loguploader/FakeLogUploader.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/ConceptCardRetriever.kt"
exempted_file_path: "domain/src/main/java/org/oppia/android/domain/topic/PrimeTopicAssetsController.kt"
diff --git a/scripts/assets/todo_open_exemptions.textproto b/scripts/assets/todo_open_exemptions.textproto
index 187cb76308c..0fa81fd297d 100644
--- a/scripts/assets/todo_open_exemptions.textproto
+++ b/scripts/assets/todo_open_exemptions.textproto
@@ -294,16 +294,16 @@ todo_open_exemption {
todo_open_exemption {
exempted_file_path: "wiki/Static-Analysis-Checks.md"
- line_number: 169
- line_number: 177
- line_number: 182
- line_number: 186
- line_number: 190
- line_number: 192
- line_number: 207
- line_number: 217
- line_number: 220
- line_number: 223
- line_number: 226
- line_number: 229
+ line_number: 170
+ line_number: 178
+ line_number: 183
+ line_number: 187
+ line_number: 191
+ line_number: 193
+ line_number: 208
+ line_number: 218
+ line_number: 221
+ line_number: 224
+ line_number: 227
+ line_number: 230
}
diff --git a/scripts/buildifier_download.sh b/scripts/buildifier_download.sh
index 3632dc1d138..9c2672f2595 100644
--- a/scripts/buildifier_download.sh
+++ b/scripts/buildifier_download.sh
@@ -3,6 +3,14 @@
# Download buildifier
BUILDIFIER="3.4.0"
echo Using Buildifier version $BUILDIFIER
-curl -sSLO https://github.com/bazelbuild/buildtools/releases/download/$BUILDIFIER/buildifier > buildifier
-chmod a+x buildifier
+if [[ "$OSTYPE" == "darwin"* ]];
+then
+ curl -sSLO https://github.com/bazelbuild/buildtools/releases/download/$BUILDIFIER/buildifier.mac > buildifier.mac
+ chmod a+x buildifier.mac
+ mv buildifier.mac buildifier
+else
+ curl -sSLO https://github.com/bazelbuild/buildtools/releases/download/$BUILDIFIER/buildifier > buildifier
+ chmod a+x buildifier
+fi
+
echo Buildifier file downloaded
diff --git a/scripts/setup.sh b/scripts/setup.sh
index feee46e80ca..8c3ef595e1d 100644
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -28,3 +28,6 @@ bash ../oppia-android/scripts/buf_download.sh
# Add protobuf platform for M1 Mac
bash ../oppia-android/scripts/buf_m1_mac_setup.sh
+
+# Download buildifier
+bash ../oppia-android/scripts/buildifier_download.sh
diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt
index a560567f3d2..4ba93385251 100644
--- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt
+++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt
@@ -173,8 +173,6 @@ class RegexPatternValidationCheckTest {
"Only colors from color_defs.xml may be used in color_palette.xml."
private val doesNotReferenceColorFromComponentColorInLayouts =
"Only colors from component_colors.xml may be used in layouts."
- private val doesNotReferenceColorFromComponentColorInDrawables =
- "Only colors from component_colors.xml may be used in drawables except vector assets."
private val doesNotReferenceColorFromComponentColorInKotlinFiles =
"Only colors from component_colors.xml may be used in Kotlin Files (Activities, Fragments, " +
"Views and Presenters)."
@@ -2346,35 +2344,7 @@ class RegexPatternValidationCheckTest {
)
}
- @Test
- fun testFileContent_xmlDrawables_includesNonColorComponentReferences_fileContentIsNotCorrect() {
- val prohibitedContent =
- """
- android:color="@color/component_color_shared_primary_text_color"
- android:color="@color/color_defs_shared_primary_text_color"
- android:color="@color/color_palette_primary_text_color"
- android:color="#003933"
- """.trimIndent()
- tempFolder.newFolder("testfiles", "app", "src", "main", "res", "drawable")
- val stringFilePath = "app/src/main/res/drawable/test_layout.xml"
- tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent)
-
- val exception = assertThrows(Exception::class) {
- runScript()
- }
-
- // Verify that all patterns are properly detected & prohibited.
- assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR)
- assertThat(outContent.toString().trim())
- .isEqualTo(
- """
- $stringFilePath:2: $doesNotReferenceColorFromComponentColorInDrawables
- $stringFilePath:3: $doesNotReferenceColorFromComponentColorInDrawables
- $stringFilePath:4: $doesNotReferenceColorFromComponentColorInDrawables
- $wikiReferenceNote
- """.trimIndent()
- )
- }
+ // TODO(#5075): Add test for drawables file that checks color uses only component colors
@Test
fun testFileContent_kotlinFiles_includesNonColorComponentReferences_fileContentIsNotCorrect() {
diff --git a/scripts/static_checks.sh b/scripts/static_checks.sh
new file mode 100644
index 00000000000..688e542602e
--- /dev/null
+++ b/scripts/static_checks.sh
@@ -0,0 +1,108 @@
+#!/bin/bash
+
+# INSTRUCTIONS
+# This script will run all script checks locally to make
+# sure that all script checks will still pass when run on
+# CI
+#
+# To run this check run the script from the Oppia-android root folder:
+#
+# bash scripts/static_checks.sh
+#
+
+# LINT CHECKS
+# Run Java lint check
+bash scripts/checkstyle_lint_check.sh
+echo ""
+
+# Run Kotlin lint check
+bash scripts/ktlint_lint_check.sh
+echo ""
+
+# Run protobuf lint checks
+bash scripts/buf_lint_check.sh
+echo ""
+
+# Download Buildifier in oppia-android-tools folder (pre-requisite for buildifier checks)
+echo "********************************"
+echo "Downloading buildifier"
+echo "********************************"
+cd ../oppia-android-tools/
+bash ../oppia-android/scripts/buildifier_download.sh
+cd ../oppia-android/
+echo ""
+
+# Run Bazel Build file lint checks (buildifier checks)
+bash scripts/buildifier_lint_check.sh
+echo ""
+
+
+# SCRIPT CHECKS
+# These checks run on Bazel. Ensure Bazel is installed and configured correctly.
+
+# Run regex pattern checks
+echo "********************************"
+echo "Running regex pattern checks"
+echo "********************************"
+bazel run //scripts:regex_pattern_validation_check -- $(pwd)
+echo ""
+
+# Run XML Syntax check validation
+echo "********************************"
+echo "Running XML Syntax validation checks"
+echo "********************************"
+bazel run //scripts:xml_syntax_check -- $(pwd)
+echo ""
+
+# Run Testfile Presence Check
+echo "********************************"
+echo "Running Testfile presence checks"
+echo "********************************"
+bazel run //scripts:test_file_check -- $(pwd)
+echo ""
+
+# Run Accessibility label Check
+echo "********************************"
+echo "Running Accessibility label checks"
+echo "********************************"
+bazel run //scripts:accessibility_label_check -- $(pwd) scripts/assets/accessibility_label_exemptions.pb app/src/main/AndroidManifest.xml
+echo ""
+
+# Run KDoc Validation Check
+echo "********************************"
+echo "Running KDoc validation checks"
+echo "********************************"
+bazel run //scripts:kdoc_validity_check -- $(pwd) scripts/assets/kdoc_validity_exemptions.pb
+echo ""
+
+# Run String resource validation check
+echo "********************************"
+echo "Running resource validation checks"
+echo "********************************"
+bazel run //scripts:string_resource_validation_check -- $(pwd)
+echo ""
+
+
+# THIRD PARTY DEPENDENCY CHECKS
+# These are checks for third party dependencies
+
+# Maven Repin Check
+echo "********************************"
+echo "Running Maven repin checks"
+echo "********************************"
+REPIN=1 bazel run @unpinned_maven//:pin
+echo ""
+
+# Maven Dependencies Update Check
+echo "********************************"
+echo "Running maven dependencies update checks"
+echo "********************************"
+bazel run //scripts:maven_dependencies_list_check -- $(pwd) third_party/maven_install.json scripts/assets/maven_dependencies.pb
+echo ""
+
+# License Texts Check
+echo "********************************"
+echo "Running license texts checks"
+echo "********************************"
+bazel run //scripts:license_texts_check -- $(pwd)/app/src/main/res/values/third_party_dependencies.xml
+
diff --git a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt
index b7c1fd9f618..cbd7163ce19 100644
--- a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt
+++ b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt
@@ -15,10 +15,12 @@ import org.oppia.android.app.model.AppLanguageSelection
import org.oppia.android.app.model.AppLanguageSelection.SelectionTypeCase.USE_SYSTEM_LANGUAGE_OR_APP_DEFAULT
import org.oppia.android.app.model.AudioTranslationLanguageSelection
import org.oppia.android.app.model.EventLog
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ABANDON_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_HINT_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.ACCESS_SOLUTION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_BACKGROUND_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_FOREGROUND_CONTEXT
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.BEGIN_SURVEY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.CLOSE_REVISION_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.DELETE_PROFILE_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.END_CARD_CONTEXT
@@ -26,6 +28,7 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.EXIT_EXP
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FINISH_EXPLORATION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.HINT_UNLOCKED_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.MANDATORY_RESPONSE
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_CONCEPT_CARD
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_HOME
@@ -41,13 +44,18 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PAUSE_VO
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.PLAY_VOICE_OVER_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.REACH_INVESTED_ENGAGEMENT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.RESUME_EXPLORATION_CONTEXT
+import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SHOW_SURVEY_POPUP
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SOLUTION_UNLOCKED_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_CARD_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_OVER_EXPLORATION_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SUBMIT_ANSWER_CONTEXT
import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SWITCH_IN_LESSON_LANGUAGE
+import org.oppia.android.app.model.MarketFitAnswer
import org.oppia.android.app.model.OppiaLanguage
+import org.oppia.android.app.model.SurveyQuestionName
+import org.oppia.android.app.model.UserTypeAnswer
import org.oppia.android.app.model.WrittenTranslationLanguageSelection
+import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat
// TODO(#4272): Add tests for this class.
@@ -926,6 +934,118 @@ class EventLogSubject private constructor(
return assertThat(actual.context.installIdForFailedAnalyticsLog)
}
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [ABANDON_SURVEY] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasAbandonSurveyContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(ABANDON_SURVEY)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasAbandonSurveyContext] and returns a
+ * [AbandonSurveyContextSubject] to test the corresponding context.
+ */
+ fun hasAbandonSurveyContextThat(): AbandonSurveyContextSubject {
+ hasAbandonSurveyContext()
+ return AbandonSurveyContextSubject.assertThat(
+ actual.context.abandonSurvey
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasAbandonSurveyContextThat(
+ block: AbandonSurveyContextSubject.() -> Unit
+ ) {
+ hasAbandonSurveyContextThat().block()
+ }
+
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [MANDATORY_RESPONSE] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasMandatorySurveyResponseContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(MANDATORY_RESPONSE)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasMandatorySurveyResponseContext] and returns a
+ * [MandatorySurveyResponseContextSubject] to test the corresponding context.
+ */
+ fun hasMandatorySurveyResponseContextThat(): MandatorySurveyResponseContextSubject {
+ hasMandatorySurveyResponseContext()
+ return MandatorySurveyResponseContextSubject.assertThat(
+ actual.context.mandatoryResponse
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasMandatorySurveyResponseContextThat(
+ block: MandatorySurveyResponseContextSubject.() -> Unit
+ ) {
+ hasMandatorySurveyResponseContextThat().block()
+ }
+
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [SHOW_SURVEY_POPUP] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasShowSurveyPopupContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(SHOW_SURVEY_POPUP)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasShowSurveyPopupContext] and returns a
+ * [SurveyContextSubject] to test the corresponding context.
+ */
+ fun hasShowSurveyPopupContextThat(): SurveyContextSubject {
+ hasShowSurveyPopupContext()
+ return SurveyContextSubject.assertThat(
+ actual.context.showSurveyPopup
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasShowSurveyPopupContextThat(
+ block: SurveyContextSubject.() -> Unit
+ ) {
+ hasShowSurveyPopupContextThat().block()
+ }
+
+ /**
+ * Verifies that the [EventLog] under test has a context corresponding to
+ * [BEGIN_SURVEY] (per [EventLog.Context.getActivityContextCase]).
+ */
+ fun hasBeginSurveyContext() {
+ assertThat(actual.context.activityContextCase).isEqualTo(BEGIN_SURVEY)
+ }
+
+ /**
+ * Verifies the [EventLog]'s context per [hasBeginSurveyContext] and returns a
+ * [SurveyContextSubject] to test the corresponding context.
+ */
+ fun hasBeginSurveyContextThat(): SurveyContextSubject {
+ hasBeginSurveyContext()
+ return SurveyContextSubject.assertThat(
+ actual.context.beginSurvey
+ )
+ }
+
+ /**
+ * Verifies the [EventLog]'s context and executes [block].
+ */
+ fun hasBeginSurveyContextThat(
+ block: SurveyContextSubject.() -> Unit
+ ) {
+ hasBeginSurveyContextThat().block()
+ }
+
/**
* Truth subject for verifying properties of [AppLanguageSelection]s.
*
@@ -1634,6 +1754,196 @@ class EventLogSubject private constructor(
}
}
+ /**
+ * Truth subject for verifying properties of [EventLog.MandatorySurveyResponseContext]s.
+ *
+ * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
+ * [EventLog.MandatorySurveyResponseContext] proto can be verified through inherited methods.
+ *
+ * Call [MandatorySurveyResponseContextSubject.assertThat] to create the subject.
+ */
+ class MandatorySurveyResponseContextSubject private constructor(
+ metadata: FailureMetadata,
+ private val actual: EventLog.MandatorySurveyResponseContext
+ ) : LiteProtoSubject(metadata, actual) {
+ /**
+ * Returns a [SurveyResponseContextSubject] to test
+ * [EventLog.AbandonSurveyContext.getSurveyDetails].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasSurveyDetailsThat(): SurveyResponseContextSubject =
+ SurveyResponseContextSubject.assertThat(actual.surveyDetails)
+
+ /** Executes [block] in the context returned by [hasSurveyDetailsThat]. */
+ fun hasSurveyDetailsThat(block: SurveyResponseContextSubject.() -> Unit) {
+ hasSurveyDetailsThat().block()
+ }
+
+ /**
+ * Returns a [ComparableSubject] to test
+ * [EventLog.MandatorySurveyResponseContext.getUserTypeAnswer].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasUserTypeAnswerThat(): ComparableSubject =
+ assertThat(actual.userTypeAnswer)
+
+ /**
+ * Returns a [ComparableSubject] to test
+ * [EventLog.MandatorySurveyResponseContext.getMarketFitAnswer].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasMarketFitAnswerThat(): ComparableSubject =
+ assertThat(actual.marketFitAnswer)
+
+ /**
+ * Returns a [ComparableSubject] to test
+ * [EventLog.MandatorySurveyResponseContext.getUserTypeAnswer].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasNpsScoreAnswerThat(): IntegerSubject =
+ assertThat(actual.npsScoreAnswer)
+
+ companion object {
+ /**
+ * Returns a new [AbandonSurveyContextSubject] to verify aspects of the specified
+ * [EventLog.AbandonSurveyContext] value.
+ */
+ fun assertThat(actual: EventLog.MandatorySurveyResponseContext):
+ MandatorySurveyResponseContextSubject =
+ assertAbout(::MandatorySurveyResponseContextSubject).that(actual)
+ }
+ }
+
+ /**
+ * Truth subject for verifying properties of [EventLog.AbandonSurveyContext]s.
+ *
+ * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying
+ * [EventLog.AbandonSurveyContext] proto can be verified through inherited methods.
+ *
+ * Call [AbandonSurveyContextSubject.assertThat] to create the subject.
+ */
+ class AbandonSurveyContextSubject private constructor(
+ metadata: FailureMetadata,
+ private val actual: EventLog.AbandonSurveyContext
+ ) : LiteProtoSubject(metadata, actual) {
+ /**
+ * Returns a [SurveyResponseContextSubject] to test
+ * [EventLog.AbandonSurveyContext.getSurveyDetails].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasSurveyDetailsThat(): SurveyResponseContextSubject =
+ SurveyResponseContextSubject.assertThat(actual.surveyDetails)
+
+ /** Executes [block] in the context returned by [hasSurveyDetailsThat]. */
+ fun hasSurveyDetailsThat(block: SurveyResponseContextSubject.() -> Unit) {
+ hasSurveyDetailsThat().block()
+ }
+
+ /**
+ * Returns a [ComparableSubject] to test [EventLog.AbandonSurveyContext.getQuestionName].
+ *
+ * This method never fails since the underlying property defaults to empty object if it's not
+ * defined in the context.
+ */
+ fun hasQuestionNameThat(): ComparableSubject