From 9a2e62710dc808c88678a681a91ac8714491da8e Mon Sep 17 00:00:00 2001 From: "Mr. 17" Date: Mon, 24 Jun 2024 15:21:50 +0530 Subject: [PATCH] Fix part of #5344: Update models to support classrooms (#5418) ## Explanation Fixes part of #5344 - Introduces models for `ClassroomIdList`, `ClassroomSummary`, & `EphemeralClassroomSummary` and updates `ClassroomList` to contain a list of `EphemeralClassroomSummary`s and a `written_translation_context`. - Topic and Story models are updated to include reference to `classroomId` & `classroomTitle`. - Test json & textproto data files are also added for 3 test classrooms, alongwith updating existing topic data files to include classroom information. ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only If your PR includes UI-related changes, then: - Add screenshots for portrait/landscape for both a tablet & phone of the before & after UI changes - For the screenshots above, include both English and pseudo-localized (RTL) screenshots (see [RTL guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines)) - Add a video showing the full UX flow with a screen reader enabled (see [accessibility guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide)) - For PRs introducing new UI elements or color changes, both light and dark mode screenshots must be included - Add a screenshot demonstrating that you ran affected Espresso tests locally & that they're passing --- domain/BUILD.bazel | 5 + domain/domain_assets.bzl | 15 +- domain/src/main/assets/classrooms.json | 11 +- domain/src/main/assets/classrooms.textproto | 34 +-- .../src/main/assets/test_classroom_id_0.json | 13 ++ .../main/assets/test_classroom_id_0.textproto | 20 ++ .../src/main/assets/test_classroom_id_1.json | 13 ++ .../main/assets/test_classroom_id_1.textproto | 17 ++ .../src/main/assets/test_classroom_id_2.json | 12 ++ .../main/assets/test_classroom_id_2.textproto | 12 ++ .../domain/topic/TopicListController.kt | 194 +++++++++++++++--- .../domain/topic/TopicListControllerTest.kt | 60 ++++++ model/src/main/proto/topic.proto | 69 ++++++- 13 files changed, 396 insertions(+), 79 deletions(-) create mode 100644 domain/src/main/assets/test_classroom_id_0.json create mode 100644 domain/src/main/assets/test_classroom_id_0.textproto create mode 100644 domain/src/main/assets/test_classroom_id_1.json create mode 100644 domain/src/main/assets/test_classroom_id_1.textproto create mode 100644 domain/src/main/assets/test_classroom_id_2.json create mode 100644 domain/src/main/assets/test_classroom_id_2.textproto diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel index 658fd1e6df9..69a5a4a35c9 100755 --- a/domain/BUILD.bazel +++ b/domain/BUILD.bazel @@ -37,6 +37,11 @@ MIGRATED_PROD_FILES = glob([ DOMAIN_ASSETS = generate_assets_list_from_text_protos( name = "domain_assets", + classroom_file_names = [ + "test_classroom_id_0", + "test_classroom_id_1", + "test_classroom_id_2", + ], classroom_list_file_names = [ "classrooms", ], diff --git a/domain/domain_assets.bzl b/domain/domain_assets.bzl index 39d7ea486c8..fe853e6707e 100644 --- a/domain/domain_assets.bzl +++ b/domain/domain_assets.bzl @@ -6,6 +6,7 @@ load("//model:text_proto_assets.bzl", "generate_proto_binary_assets") def generate_assets_list_from_text_protos( name, + classroom_file_names, classroom_list_file_names, topic_file_names, subtopic_file_names, @@ -17,6 +18,7 @@ def generate_assets_list_from_text_protos( Args: name: str. The name of this generation instance. This will be a prefix for derived targets. + classroom_file_names: list of str. The list of classroom file names. classroom_list_file_names: list of str. The classroom list file names. topic_file_names: list of str. The list of topic file names. subtopic_file_names: list of str. The list of subtopic file names. @@ -31,8 +33,17 @@ def generate_assets_list_from_text_protos( name = name, names = classroom_list_file_names, proto_dep_name = "topic", - proto_type_name = "ClassroomList", - name_prefix = "classroom_list", + proto_type_name = "ClassroomIdList", + name_prefix = "classroom_id_list", + asset_dir = "src/main/assets", + proto_dep_bazel_target_prefix = "//model/src/main/proto", + proto_package = "model", + ) + generate_proto_binary_assets( + name = name, + names = classroom_file_names, + proto_dep_name = "topic", + proto_type_name = "ClassroomRecord", + name_prefix = "classroom_record", asset_dir = "src/main/assets", proto_dep_bazel_target_prefix = "//model/src/main/proto", proto_package = "model", diff --git a/domain/src/main/assets/classrooms.json b/domain/src/main/assets/classrooms.json index eb92a2f7094..8a0d9bb44c3 100644 --- a/domain/src/main/assets/classrooms.json +++ b/domain/src/main/assets/classrooms.json @@ -1,12 +1,3 @@ { - "classrooms": [{ - "id": "test_classroom_id_0", - "topic_prerequisites": { - "test_topic_id_0": ["GJ2rLXRKD5hw"], - "test_topic_id_1": ["test_topic_id_0", "omzF4oqgeTXd"], - "test_topic_id_2": [], - "GJ2rLXRKD5hw": [], - "omzF4oqgeTXd": [] - } - }] + "classroom_id_list": ["test_classroom_id_0", "test_classroom_id_1", "test_classroom_id_2"] } diff --git a/domain/src/main/assets/classrooms.textproto b/domain/src/main/assets/classrooms.textproto index 05b4af4eb0a..5db8407d9a8 100644 --- a/domain/src/main/assets/classrooms.textproto +++ b/domain/src/main/assets/classrooms.textproto @@ -1,31 +1,3 @@ -classrooms { - id: "test_classroom_id_0" - topic_prerequisites { - key: "test_topic_id_0" - value { - topic_ids: "GJ2rLXRKD5hw" - } - } - topic_prerequisites { - key: "test_topic_id_1" - value { - topic_ids: "test_topic_id_0" - topic_ids: "omzF4oqgeTXd" - } - } - topic_prerequisites { - key: "test_topic_id_2" - value { - } - } - topic_prerequisites { - key: "GJ2rLXRKD5hw" - value { - } - } - topic_prerequisites { - key: "omzF4oqgeTXd" - value { - } - } -} +classroom_ids: "test_classroom_id_0" +classroom_ids: "test_classroom_id_1" +classroom_ids: "test_classroom_id_2" diff --git a/domain/src/main/assets/test_classroom_id_0.json b/domain/src/main/assets/test_classroom_id_0.json new file mode 100644 index 00000000000..97c33f70bf2 --- /dev/null +++ b/domain/src/main/assets/test_classroom_id_0.json @@ -0,0 +1,13 @@ +{ + "classroom_id": "test_classroom_id_0", + "classroom_title": { + "content_id": "classroom_title", + "html": "Science" + }, + "thumbnail_bg_color": "#00FFFFFF", + "thumbnail_filename": "", + "topic_prerequisites": { + "test_topic_id_0": ["GJ2rLXRKD5hw"], + "test_topic_id_1": ["test_topic_id_0", "omzF4oqgeTXd"] + } +} diff --git a/domain/src/main/assets/test_classroom_id_0.textproto b/domain/src/main/assets/test_classroom_id_0.textproto new file mode 100644 index 00000000000..c98e7ba4ac3 --- /dev/null +++ b/domain/src/main/assets/test_classroom_id_0.textproto @@ -0,0 +1,20 @@ +id: "test_classroom_id_0" +translatable_title { + content_id: "classroom_title" + html: "Science" +} +classroom_thumbnail { +} +topic_prerequisites { + key: "test_topic_id_0" + value { + topic_ids: "GJ2rLXRKD5hw" + } +} +topic_prerequisites { + key: "test_topic_id_1" + value { + topic_ids: "test_topic_id_0" + topic_ids: "omzF4oqgeTXd" + } +} diff --git a/domain/src/main/assets/test_classroom_id_1.json b/domain/src/main/assets/test_classroom_id_1.json new file mode 100644 index 00000000000..53043956ff0 --- /dev/null +++ b/domain/src/main/assets/test_classroom_id_1.json @@ -0,0 +1,13 @@ +{ + "classroom_id": "test_classroom_id_1", + "classroom_title": { + "content_id": "classroom_title", + "html": "Maths" + }, + "thumbnail_bg_color": "#00FFFFFF", + "thumbnail_filename": "", + "topic_prerequisites": { + "GJ2rLXRKD5hw": [], + "omzF4oqgeTXd": [] + } +} diff --git a/domain/src/main/assets/test_classroom_id_1.textproto b/domain/src/main/assets/test_classroom_id_1.textproto new file mode 100644 index 00000000000..84d10414493 --- /dev/null +++ b/domain/src/main/assets/test_classroom_id_1.textproto @@ -0,0 +1,17 @@ +id: "test_classroom_id_1" +translatable_title { + content_id: "classroom_title" + html: "Maths" +} +classroom_thumbnail { +} +topic_prerequisites { + key: "GJ2rLXRKD5hw" + value { + } +} +topic_prerequisites { + key: "omzF4oqgeTXd" + value { + } +} diff --git a/domain/src/main/assets/test_classroom_id_2.json b/domain/src/main/assets/test_classroom_id_2.json new file mode 100644 index 00000000000..55e7e9d9fe7 --- /dev/null +++ b/domain/src/main/assets/test_classroom_id_2.json @@ -0,0 +1,12 @@ +{ + "classroom_id": "test_classroom_id_2", + "classroom_title": { + "content_id": "classroom_title", + "html": "English" + }, + "thumbnail_bg_color": "#00FFFFFF", + "thumbnail_filename": "", + "topic_prerequisites": { + "test_topic_id_2": [] + } +} diff --git a/domain/src/main/assets/test_classroom_id_2.textproto b/domain/src/main/assets/test_classroom_id_2.textproto new file mode 100644 index 00000000000..a95ec162147 --- /dev/null +++ b/domain/src/main/assets/test_classroom_id_2.textproto @@ -0,0 +1,12 @@ +id: "test_classroom_id_2" +translatable_title { + content_id: "classroom_title" + html: "English" +} +classroom_thumbnail { +} +topic_prerequisites { + key: "test_topic_id_2" + value { + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt b/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt index 8936feb3d98..067763a9963 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt @@ -5,7 +5,7 @@ import org.json.JSONObject import org.oppia.android.app.model.ChapterPlayState import org.oppia.android.app.model.ChapterProgress import org.oppia.android.app.model.ChapterSummary -import org.oppia.android.app.model.ClassroomList +import org.oppia.android.app.model.ClassroomIdList import org.oppia.android.app.model.ClassroomRecord import org.oppia.android.app.model.ClassroomRecord.TopicIdList import org.oppia.android.app.model.ComingSoonTopicList @@ -59,6 +59,12 @@ const val FRACTIONS_TOPIC_ID = "GJ2rLXRKD5hw" const val SUBTOPIC_TOPIC_ID = 1 const val SUBTOPIC_TOPIC_ID_2 = 2 const val RATIOS_TOPIC_ID = "omzF4oqgeTXd" + +// TODO(#5344): Move these classroom ids to [ClassroomController]. +const val TEST_CLASSROOM_ID_0 = "test_classroom_id_0" +const val TEST_CLASSROOM_ID_1 = "test_classroom_id_1" +const val TEST_CLASSROOM_ID_2 = "test_classroom_id_2" + val TOPIC_THUMBNAILS = mapOf( FRACTIONS_TOPIC_ID to createTopicThumbnail0(), RATIOS_TOPIC_ID to createTopicThumbnail1(), @@ -135,7 +141,7 @@ class TopicListController @Inject constructor( private fun createTopicList(contentLocale: OppiaLocale.ContentLocale): TopicList { return if (loadLessonProtosFromAssets) { - val topicIdList = loadCombinedClassroomTopicList() + val topicIdList = loadCombinedClassroomsTopicIdList() return TopicList.newBuilder().apply { // Only include topics currently playable in the topic list. addAllTopicSummary( @@ -150,7 +156,7 @@ class TopicListController @Inject constructor( } private fun loadTopicListFromJson(contentLocale: OppiaLocale.ContentLocale): TopicList { - val topicIdList = loadCombinedClassroomTopicList() + val topicIdList = loadCombinedClassroomsTopicIdList() val topicListBuilder = TopicList.newBuilder() for (topicId in topicIdList) { val ephemeralSummary = createEphemeralTopicSummary(topicId, contentLocale) @@ -164,7 +170,7 @@ class TopicListController @Inject constructor( } private fun computeComingSoonTopicList(): ComingSoonTopicList { - val topicIdList = loadCombinedClassroomTopicList() + val topicIdList = loadCombinedClassroomsTopicIdList() val comingSoonTopicListBuilder = ComingSoonTopicList.newBuilder() for (topicId in topicIdList) { val upcomingTopicSummary = createUpcomingTopicSummary(topicId) @@ -183,12 +189,18 @@ class TopicListController @Inject constructor( contentLocale: OppiaLocale.ContentLocale ): EphemeralTopicSummary { val topicSummary = createTopicSummary(topicId) + val classroomRecord = loadClassroomById(topicSummary.classroomId) return EphemeralTopicSummary.newBuilder().apply { this.topicSummary = topicSummary writtenTranslationContext = translationController.computeWrittenTranslationContext( topicSummary.writtenTranslationsMap, contentLocale ) + classroomWrittenTranslationContext = + translationController.computeWrittenTranslationContext( + classroomRecord.writtenTranslationsMap, contentLocale + ) + classroomTitle = classroomRecord.translatableTitle }.build() } @@ -209,6 +221,7 @@ class TopicListController @Inject constructor( this.topicId = topicId putAllWrittenTranslations(topicRecord.writtenTranslationsMap) title = topicRecord.translatableTitle + classroomId = getClassroomIdByTopicId(topicId) totalChapterCount = storyRecords.map { it.chaptersList.size }.sum() topicThumbnail = topicRecord.topicThumbnail topicPlayAvailability = if (topicRecord.isPublished) { @@ -250,10 +263,12 @@ class TopicListController @Inject constructor( contentId = "title" html = jsonObject.getStringFromObject("topic_name") }.build() + val classroomId = getClassroomIdByTopicId(topicId) // No written translations are included since none are retrieved from JSON. return TopicSummary.newBuilder() .setTopicId(topicId) .setTitle(topicTitle) + .setClassroomId(classroomId) .setVersion(jsonObject.optInt("version")) .setTotalChapterCount(totalChapterCount) .setTopicThumbnail(createTopicThumbnailFromJson(jsonObject)) @@ -285,9 +300,21 @@ class TopicListController @Inject constructor( html = jsonObject.getStringFromObject("topic_name") }.build() + val classroomId = getClassroomIdByTopicId(topicId) + + val classroomJsonObject = jsonAssetRetriever.loadJsonFromAsset("$classroomId.json")!! + val classroomTitle = classroomJsonObject.getJSONObject("classroom_title").let { + SubtitledHtml.newBuilder().apply { + contentId = it.getStringFromObject("content_id") + html = it.getStringFromObject("html") + }.build() + } + // No written translations are included since none are retrieved from JSON. return UpcomingTopic.newBuilder().setTopicId(topicId) .setTitle(topicTitle) + .setClassroomId(classroomId) + .setClassroomTitle(classroomTitle) .setVersion(jsonObject.optInt("version")) .setTopicPlayAvailability(topicPlayAvailability) .setLessonThumbnail(createTopicThumbnailFromJson(jsonObject)) @@ -345,6 +372,10 @@ class TopicListController @Inject constructor( sortedTopicProgressList.forEach { topicProgress -> val topic = topicController.retrieveTopic(topicProgress.topicId) + val classroom = topic?.topicId?.let { topicId -> + val classroomId = getClassroomIdByTopicId(topicId) + loadClassroomById(classroomId) + } ?: ClassroomRecord.getDefaultInstance() // Ignore topics that are no longer on the device, or that have been unpublished. if (topic?.topicPlayAvailability?.availabilityCase == AVAILABLE_TO_PLAY_NOW) { val isTopicConsideredCompleted = topic.hasAtLeastOneStoryCompleted(topicProgress) @@ -373,7 +404,8 @@ class TopicListController @Inject constructor( topic, isTopicConsideredCompleted, storyProgress.chapterProgressMap, - contentLocale + contentLocale, + classroom )?.let { promotedStory -> playedPromotedStoryList.add(promotedStory) } @@ -393,7 +425,8 @@ class TopicListController @Inject constructor( topic, isTopicConsideredCompleted, storyProgress.chapterProgressMap, - contentLocale + contentLocale, + classroom )?.let { promotedStory -> playedPromotedStoryList.add(promotedStory) } @@ -460,7 +493,8 @@ class TopicListController @Inject constructor( topic: Topic, isTopicConsideredCompleted: Boolean, chapterProgressMap: Map, - contentLocale: OppiaLocale.ContentLocale + contentLocale: OppiaLocale.ContentLocale, + classroom: ClassroomRecord, ): PromotedStory? { val recentlyPlayerChapterSummary: ChapterSummary? = story.chapterList.find { chapterSummary -> @@ -475,7 +509,8 @@ class TopicListController @Inject constructor( recentlyPlayerChapterSummary, isTopicConsideredCompleted, chapterProgressMap[recentlyPlayerChapterSummary.explorationId], - contentLocale + contentLocale, + classroom ) } return null @@ -489,7 +524,8 @@ class TopicListController @Inject constructor( topic: Topic, isTopicConsideredCompleted: Boolean, chapterProgressMap: Map, - contentLocale: OppiaLocale.ContentLocale + contentLocale: OppiaLocale.ContentLocale, + classroom: ClassroomRecord, ): PromotedStory? { val lastChapterSummary: ChapterSummary? = story.chapterList.find { chapterSummary -> @@ -507,7 +543,8 @@ class TopicListController @Inject constructor( nextChapterSummary, isTopicConsideredCompleted, chapterProgressMap[nextChapterSummary.explorationId], - contentLocale + contentLocale, + classroom ) } } @@ -522,8 +559,15 @@ class TopicListController @Inject constructor( * Returns a list of topic IDs for which the specified topic ID expects to be completed before * being suggested. */ - private fun retrieveTopicDependencies(topicId: String): List = - loadClassroom().topicPrerequisitesMap.getValue(topicId).topicIdsList + private fun retrieveTopicDependencies(topicId: String): List { + val classrooms = loadClassrooms() + for (classroom in classrooms) { + if (classroom.topicPrerequisitesMap.containsKey(topicId)) { + return classroom.topicPrerequisitesMap.getValue(topicId).topicIdsList + } + } + throw IllegalArgumentException("Topic ID $topicId not found in any classroom.") + } /* * Explanation for logic: @@ -549,7 +593,7 @@ class TopicListController @Inject constructor( contentLocale: OppiaLocale.ContentLocale ): List { return if (loadLessonProtosFromAssets) { - val topicIdList = loadCombinedClassroomTopicList() + val topicIdList = loadCombinedClassroomsTopicIdList() return computeSuggestedStoriesForTopicIds(topicProgressList, topicIdList, contentLocale) } else computeSuggestedStoriesFromJson(topicProgressList, contentLocale) } @@ -559,7 +603,7 @@ class TopicListController @Inject constructor( contentLocale: OppiaLocale.ContentLocale ): List { // All topics that could potentially be recommended. - val topicIdList = loadCombinedClassroomTopicList() + val topicIdList = loadCombinedClassroomsTopicIdList() return computeSuggestedStoriesForTopicIds(topicProgressList, topicIdList, contentLocale) } @@ -671,6 +715,11 @@ class TopicListController @Inject constructor( assetName = firstStoryId, baseMessage = StoryRecord.getDefaultInstance() ) + val classroomRecord = + assetRepository.loadProtoFromLocalAssets( + assetName = getClassroomIdByTopicId(topicId), + baseMessage = ClassroomRecord.getDefaultInstance() + ) return PromotedStory.newBuilder().apply { storyId = firstStoryId storyWrittenTranslationContext = @@ -681,9 +730,15 @@ class TopicListController @Inject constructor( translationController.computeWrittenTranslationContext( topicRecord.writtenTranslationsMap, contentLocale ) + classroomWrittenTranslationContext = + translationController.computeWrittenTranslationContext( + classroomRecord.writtenTranslationsMap, contentLocale + ) storyTitle = storyRecord.translatableStoryName this.topicId = topicId topicTitle = topicRecord.translatableTitle + classroomId = classroomRecord.id + classroomTitle = classroomRecord.translatableTitle completedChapterCount = 0 totalChapterCount = storyRecord.chaptersCount lessonThumbnail = storyRecord.storyThumbnail @@ -731,6 +786,19 @@ class TopicListController @Inject constructor( html = it }.build() } ?: SubtitledHtml.getDefaultInstance() + + val classroomId = getClassroomIdByTopicId(topicId) + + val classroomJson = jsonAssetRetriever.loadJsonFromAsset("$classroomId.json") + if (classroomJson!!.optString("classroom_title").isNullOrEmpty()) return null + + val classroomTitle = classroomJson.getJSONObject("classroom_title").let { + SubtitledHtml.newBuilder().apply { + contentId = it.getStringFromObject("content_id") + html = it.getStringFromObject("html") + }.build() + } + // No written translations are included for the topic since its name is directly fetched from // the JSON (and the JSON doesn't include translations for these properties, anyway). val promotedStoryBuilder = PromotedStory.newBuilder() @@ -739,6 +807,8 @@ class TopicListController @Inject constructor( .setLessonThumbnail(storySummary.storyThumbnail) .setTopicId(topicId) .setTopicTitle(topicTitle) + .setClassroomId(classroomId) + .setClassroomTitle(classroomTitle) .setCompletedChapterCount(0) .setTotalChapterCount(totalChapterCount) if (storySummary.chapterList.isNotEmpty()) { @@ -758,7 +828,8 @@ class TopicListController @Inject constructor( nextChapterSummary: ChapterSummary, isTopicConsideredCompleted: Boolean, nextChapterProgress: ChapterProgress?, - contentLocale: OppiaLocale.ContentLocale + contentLocale: OppiaLocale.ContentLocale, + classroom: ClassroomRecord, ): PromotedStory { val storySummary = topic.storyList.find { summary -> summary.storyId == storyId }!! // If the chapterProgress equals null that means the chapter has no progress associated with @@ -780,10 +851,17 @@ class TopicListController @Inject constructor( nextChapterSummary.writtenTranslationsMap, contentLocale ) ) + .setClassroomWrittenTranslationContext( + translationController.computeWrittenTranslationContext( + classroom.writtenTranslationsMap, contentLocale + ) + ) .setStoryTitle(storySummary.storyTitle) .setLessonThumbnail(storySummary.storyThumbnail) .setTopicId(topic.topicId) .setTopicTitle(topic.title) + .setClassroomId(classroom.id) + .setClassroomTitle(classroom.translatableTitle) .setCompletedChapterCount(completedChapterCount) .setTotalChapterCount(totalChapterCount) .setIsTopicLearned(isTopicConsideredCompleted) @@ -793,25 +871,71 @@ class TopicListController @Inject constructor( .build() } + private fun getClassroomIdByTopicId(topicId: String): String { + var classroomId = TEST_CLASSROOM_ID_0 + loadClassrooms().forEach { + if (it.topicPrerequisitesMap.keys.contains(topicId)) { + classroomId = it.id + } + } + return classroomId + } + // TODO(#5344): Remove this in favor of per-classroom data handling. - private fun loadClassroom(): ClassroomRecord { + private fun loadClassrooms(): List { return if (loadLessonProtosFromAssets) { - return assetRepository.loadProtoFromLocalAssets( + assetRepository.loadProtoFromLocalAssets( assetName = "classrooms", - baseMessage = ClassroomList.getDefaultInstance() - ).classroomsList.single() // Only one record is currently expected. - } else loadClassroomFromJson() + baseMessage = ClassroomIdList.getDefaultInstance() + ).classroomIdsList.map { classroomId -> + loadClassroomById(classroomId) + } + } else loadClassroomsFromJson() + } + + // TODO(#5344): Remove this in favor of per-classroom data handling. + private fun loadClassroomsFromJson(): List { + // Load the classrooms.json file. + val classroomIdsObj = jsonAssetRetriever.loadJsonFromAsset("classrooms.json") + checkNotNull(classroomIdsObj) { "Failed to load classrooms.json." } + val classroomIds = classroomIdsObj.optJSONArray("classroom_id_list") + checkNotNull(classroomIds) { "classrooms.json is missing classroom IDs." } + + // Initialize a list to store the [ClassroomRecord]s. + val classroomRecords = mutableListOf() + + // Iterate over all classroomIds and load each classroom's JSON. + for (i in 0 until classroomIds.length()) { + val classroomId = checkNotNull(classroomIds.optString(i)) { + "Expected non-null classroom ID at index $i." + } + val classroomRecord = loadClassroomById(classroomId) + classroomRecords.add(classroomRecord) + } + + return classroomRecords + } + + // TODO(#5344): Move this to classroom controller. + private fun loadClassroomById(classroomId: String): ClassroomRecord { + return if (loadLessonProtosFromAssets) { + assetRepository.tryLoadProtoFromLocalAssets( + assetName = classroomId, + defaultMessage = ClassroomRecord.getDefaultInstance() + ) ?: ClassroomRecord.getDefaultInstance() + } else loadClassroomByIdFromJson(classroomId) } // TODO(#5344): Remove this in favor of per-classroom data handling. - private fun loadClassroomFromJson(): ClassroomRecord { - val classroomsObj = jsonAssetRetriever.loadJsonFromAsset("classrooms.json") - checkNotNull(classroomsObj) { "Failed to load classrooms.json." } - val classroomArray = classroomsObj.optJSONArray("classrooms") - checkNotNull(classroomArray) { "classrooms.json missing classrooms array." } - check(classroomArray.length() == 1) { "Expected classrooms.json to have one single classroom." } - val classroom = checkNotNull(classroomArray.optJSONObject(0)) { "Expected non-null classroom." } - val topicPrereqsObj = checkNotNull(classroom.optJSONObject("topic_prerequisites")) { + private fun loadClassroomByIdFromJson(classroomId: String): ClassroomRecord { + // Load the classroom obj. + val classroomObj = jsonAssetRetriever.loadJsonFromAsset("$classroomId.json") + checkNotNull(classroomObj) { "Failed to load $classroomId.json." } + + val classroomTitle = classroomObj.getJSONObject("classroom_title") + + // Load the topic prerequisite map. + val topicPrereqsObj = checkNotNull(classroomObj.optJSONObject("topic_prerequisites")) { "Expected classroom to have non-null topic_prerequisites." } val topicPrereqs = topicPrereqsObj.keys().asSequence().associateWith { topicId -> @@ -825,8 +949,14 @@ class TopicListController @Inject constructor( } } return ClassroomRecord.newBuilder().apply { - this.id = checkNotNull(classroom.optString("id")) { "Expected classroom to have ID." } - this.putAllTopicPrerequisites( + id = checkNotNull(classroomObj.optString("classroom_id")) { + "Expected classroom to have ID." + } + translatableTitle = SubtitledHtml.newBuilder().apply { + contentId = classroomTitle.getStringFromObject("content_id") + html = classroomTitle.getStringFromObject("html") + }.build() + putAllTopicPrerequisites( topicPrereqs.mapValues { (_, topicIds) -> TopicIdList.newBuilder().apply { addAllTopicIds(topicIds) @@ -837,8 +967,8 @@ class TopicListController @Inject constructor( } // TODO(#5344): Remove this in favor of per-classroom data handling. - private fun loadCombinedClassroomTopicList(): List = - loadClassroom().topicPrerequisitesMap.keys.toList() + private fun loadCombinedClassroomsTopicIdList(): List = + loadClassrooms().flatMap { it.topicPrerequisitesMap.keys.toList() } } internal fun createTopicThumbnailFromJson(topicJsonObject: JSONObject): LessonThumbnail { diff --git a/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt index 28bcf2e44cd..29006f7e528 100644 --- a/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt @@ -108,6 +108,15 @@ class TopicListControllerTest { assertThat(firstTopic.title.html).isEqualTo("First Test Topic") } + @Test + fun testRetrieveTopicList_firstTopic_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList() + + val firstTopic = topicList.getTopicSummary(0) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Science") + } + @Test fun testRetrieveTopicList_firstTopic_hasCorrectLessonCount() { val topicList = retrieveTopicList() @@ -125,6 +134,15 @@ class TopicListControllerTest { assertThat(secondTopic.title.html).isEqualTo("Second Test Topic") } + @Test + fun testRetrieveTopicList_secondTopic_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList() + + val firstTopic = topicList.getTopicSummary(1) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Science") + } + @Test fun testRetrieveTopicList_secondTopic_hasCorrectLessonCount() { val topicList = retrieveTopicList() @@ -142,6 +160,15 @@ class TopicListControllerTest { assertThat(fractionsTopic.title.html).isEqualTo("Fractions") } + @Test + fun testRetrieveTopicList_fractionsTopic_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList() + + val firstTopic = topicList.getTopicSummary(2) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Maths") + } + @Test fun testRetrieveTopicList_fractionsTopic_hasCorrectLessonCount() { val topicList = retrieveTopicList() @@ -159,6 +186,15 @@ class TopicListControllerTest { assertThat(ratiosTopic.title.html).isEqualTo("Ratios and Proportional Reasoning") } + @Test + fun testRetrieveTopicList_ratiosTopic_hasCorrectClassroomInfo() { + val topicList = retrieveTopicList() + + val firstTopic = topicList.getTopicSummary(3) + assertThat(firstTopic.topicSummary.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(firstTopic.classroomTitle.html).isEqualTo("Maths") + } + @Test fun testRetrieveTopicList_ratiosTopic_hasCorrectLessonCount() { val topicList = retrieveTopicList() @@ -679,6 +715,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_0) assertThat(promotedStory.topicTitle.html).isEqualTo("First Test Topic") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Science") assertThat(promotedStory.nextChapterTitle.html).isEqualTo("Prototype Exploration") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -690,6 +728,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_0) assertThat(promotedStory.topicTitle.html).isEqualTo("First Test Topic") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Science") assertThat(promotedStory.nextChapterTitle.html).isEqualTo("Prototype Exploration") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -701,6 +741,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(TEST_STORY_ID_2) assertThat(promotedStory.topicId).isEqualTo(TEST_TOPIC_ID_1) assertThat(promotedStory.topicTitle.html).isEqualTo("Second Test Topic") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_0) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Science") assertThat(promotedStory.nextChapterTitle.html).isEqualTo("Fifth Exploration") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -712,6 +754,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(FRACTIONS_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(FRACTIONS_TOPIC_ID) assertThat(promotedStory.topicTitle.html).isEqualTo("Fractions") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.nextChapterTitle.html).isEqualTo("What is a Fraction?") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -723,6 +767,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(FRACTIONS_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(FRACTIONS_TOPIC_ID) assertThat(promotedStory.topicTitle.html).isEqualTo("Fractions") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.nextChapterTitle.html).isEqualTo("What is a Fraction?") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -734,6 +780,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(FRACTIONS_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(FRACTIONS_TOPIC_ID) assertThat(promotedStory.topicTitle.html).isEqualTo("Fractions") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.nextChapterTitle.html).isEqualTo("The Meaning of Equal Parts") assertThat(promotedStory.completedChapterCount).isEqualTo(1) assertThat(promotedStory.totalChapterCount).isEqualTo(2) @@ -744,6 +792,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(RATIOS_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(RATIOS_TOPIC_ID) assertThat(promotedStory.nextChapterTitle.html).isEqualTo("What is a Ratio?") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.topicTitle.html).isEqualTo("Ratios and Proportional Reasoning") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -755,6 +805,8 @@ class TopicListControllerTest { assertThat(promotedStory.storyId).isEqualTo(RATIOS_STORY_ID_0) assertThat(promotedStory.topicId).isEqualTo(RATIOS_TOPIC_ID) assertThat(promotedStory.nextChapterTitle.html).isEqualTo("What is a Ratio?") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.topicTitle.html).isEqualTo("Ratios and Proportional Reasoning") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isFalse() @@ -767,6 +819,8 @@ class TopicListControllerTest { assertThat(promotedStory.topicId).isEqualTo(RATIOS_TOPIC_ID) assertThat(promotedStory.nextChapterTitle.html).isEqualTo("Order is important") assertThat(promotedStory.topicTitle.html).isEqualTo("Ratios and Proportional Reasoning") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.completedChapterCount).isEqualTo(1) assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) @@ -775,6 +829,8 @@ class TopicListControllerTest { private fun verifyUpcomingTopic1(upcomingTopic: UpcomingTopic) { assertThat(upcomingTopic.topicId).isEqualTo(UPCOMING_TOPIC_ID_1) assertThat(upcomingTopic.title.html).isEqualTo("Third Test Topic") + assertThat(upcomingTopic.classroomId).isEqualTo(TEST_CLASSROOM_ID_2) + assertThat(upcomingTopic.classroomTitle.html).isEqualTo("English") } private fun verifyOngoingStoryAsRatioStory1Exploration2( @@ -786,6 +842,8 @@ class TopicListControllerTest { assertThat(promotedStory.topicId).isEqualTo(RATIOS_TOPIC_ID) assertThat(promotedStory.nextChapterTitle.html).isEqualTo("Equivalent Ratios") assertThat(promotedStory.topicTitle.html).isEqualTo("Ratios and Proportional Reasoning") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.completedChapterCount).isEqualTo(0) assertThat(promotedStory.isTopicLearned).isEqualTo(expectedToBeLearned) assertThat(promotedStory.totalChapterCount).isEqualTo(2) @@ -797,6 +855,8 @@ class TopicListControllerTest { assertThat(promotedStory.topicId).isEqualTo(RATIOS_TOPIC_ID) assertThat(promotedStory.nextChapterTitle.html).isEqualTo("Writing Ratios in Simplest Form") assertThat(promotedStory.topicTitle.html).isEqualTo("Ratios and Proportional Reasoning") + assertThat(promotedStory.classroomId).isEqualTo(TEST_CLASSROOM_ID_1) + assertThat(promotedStory.classroomTitle.html).isEqualTo("Maths") assertThat(promotedStory.completedChapterCount).isEqualTo(1) assertThat(promotedStory.isTopicLearned).isFalse() assertThat(promotedStory.totalChapterCount).isEqualTo(2) diff --git a/model/src/main/proto/topic.proto b/model/src/main/proto/topic.proto index 004980d147a..6ec81bca22d 100755 --- a/model/src/main/proto/topic.proto +++ b/model/src/main/proto/topic.proto @@ -277,6 +277,9 @@ message PromotedStory { // The written translation context strings corresponding to the next chapter of this story. WrittenTranslationContext next_chapter_written_translation_context = 14; + // The written translation context for the story's classroom strings. + WrittenTranslationContext classroom_written_translation_context = 20; + // The title of the story being promoted. SubtitledHtml story_title = 15; @@ -289,6 +292,12 @@ message PromotedStory { // The title of the next chapter (exploration title) to complete. SubtitledHtml next_chapter_title = 17; + // The ID of the classroom this story is part of. + string classroom_id = 18; + + // The title of the classroom this story is part of. + SubtitledHtml classroom_title = 19; + // The exploration id next chapter to complete. string exploration_id = 6; @@ -331,6 +340,9 @@ message TopicSummary { // The title of the topic. SubtitledHtml title = 8; + // The ID of the classroom this topic is part of. + string classroom_id = 10; + // The structural version of the topic. int32 version = 3; @@ -358,6 +370,12 @@ message EphemeralTopicSummary { // The translation context that should be used for this topic summary. WrittenTranslationContext written_translation_context = 2; + // The written translation context for the topic's classroom strings. + WrittenTranslationContext classroom_written_translation_context = 3; + + // The title of the classroom this topic is part of. + SubtitledHtml classroom_title = 4; + // The ID of the first story of this topic. string first_story_id = 7; } @@ -441,9 +459,18 @@ message UpcomingTopic { // The written translation context for this topic view. WrittenTranslationContext written_translation_context = 6; + // The written translation context for the topic's classroom strings. + WrittenTranslationContext classroom_written_translation_context = 10; + // The title of the topic. SubtitledHtml title = 7; + // The ID of the classroom this topic is part of. + string classroom_id = 8; + + // The title of the classroom this topic is part of. + SubtitledHtml classroom_title = 9; + // The structural version of the topic. int32 version = 3; @@ -547,10 +574,10 @@ message EphemeralRevisionCard { WrittenTranslationContext written_translation_context = 2; } -// Corresponds to a local file cataloging all available classrooms in the app. -message ClassroomList { - // The list of classrooms available to the app. - repeated ClassroomRecord classrooms = 1; +// Corresponds to a local file cataloging all classrooms available to load. +message ClassroomIdList { + // The list of IDs corresponding to classrooms available on the local filesystem. + repeated string classroom_ids = 1; } // Corresponds to a loadable classroom. @@ -581,6 +608,40 @@ message ClassroomRecord { } } +// A summary of a classroom which contains a list of topic summaries. +message ClassroomSummary { + // The ID of the classroom. + string classroom_id = 1; + + // Mapping from content_id to a TranslationMapping for each SubtitledHtml in this classroom that + // has a corresponding translation. + map written_translations = 2; + + // The title of the classroom. + SubtitledHtml classroom_title = 3; + + // The thumbnail corresponding to this classroom. + LessonThumbnail classroom_thumbnail = 4; + + // A list of topic summaries contained within this classroom. + repeated TopicSummary topic_summary = 5; +} + +// Corresponds to a classroom summary that is currently being viewed. +message EphemeralClassroomSummary { + // The classroom summary to view. + ClassroomSummary classroom_summary = 1; + + // The translation context that should be used for this classroom summary. + WrittenTranslationContext written_translation_context = 2; +} + +// Corresponds to the list of classrooms that can be shown on the classroom list screen. +message ClassroomList { + // All classrooms that are available to the player. + repeated EphemeralClassroomSummary classroom_summary = 1; +} + // Corresponds to a local file cataloging all concept cards available to load. message ConceptCardList { // The list of concept cards stored on the local filesystem.