diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityEndpointApi.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityEndpointApi.kt new file mode 100644 index 00000000000..04f7bfa3a90 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityEndpointApi.kt @@ -0,0 +1,67 @@ +package org.oppia.android.scripts.gae.json + +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query + +internal interface AndroidActivityEndpointApi { + @GET("android_data?activity_type=classroom") + fun fetchLatestClassroom( + @Query("activities_data") request: AndroidActivityRequests.Latest + ): Call>> + + @GET("android_data?activity_type=exploration") + fun fetchLatestExploration( + @Query("activities_data") request: AndroidActivityRequests.Latest + ): Call>> + + @GET("android_data?activity_type=exploration") + fun fetchExplorationByVersion( + @Query("activities_data") request: AndroidActivityRequests.NonLocalized + ): Call>> + + @GET("android_data?activity_type=story") + fun fetchLatestStory( + @Query("activities_data") request: AndroidActivityRequests.Latest + ): Call>> + + @GET("android_data?activity_type=story") + fun fetchStoryByVersion( + @Query("activities_data") request: AndroidActivityRequests.NonLocalized + ): Call>> + + @GET("android_data?activity_type=skill") + fun fetchLatestConceptCard( + @Query("activities_data") request: AndroidActivityRequests.Latest + ): Call>> + + @GET("android_data?activity_type=skill") + fun fetchConceptCardByVersion( + @Query("activities_data") request: AndroidActivityRequests.NonLocalized + ): Call>> + + @GET("android_data?activity_type=subtopic") + fun fetchLatestRevisionCard( + @Query("activities_data") request: AndroidActivityRequests.Latest + ): Call>> + + @GET("android_data?activity_type=subtopic") + fun fetchRevisionCardByVersion( + @Query("activities_data") request: AndroidActivityRequests.NonLocalized + ): Call>> + + @GET("android_data?activity_type=learntopic") + fun fetchLatestTopic( + @Query("activities_data") request: AndroidActivityRequests.Latest + ): Call>> + + @GET("android_data?activity_type=learntopic") + fun fetchTopicByVersion( + @Query("activities_data") request: AndroidActivityRequests.NonLocalized + ): Call>> + + @GET("android_data?activity_type=exp_translations") + fun fetchExplorationTranslations( + @Query("activities_data") request: AndroidActivityRequests.Localized + ): Call>> +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityHandlerService.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityHandlerService.kt new file mode 100644 index 00000000000..2cb5cd5b895 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityHandlerService.kt @@ -0,0 +1,457 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.rawType +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import org.oppia.android.scripts.gae.json.AndroidActivityRequests.ActivityRequest +import org.oppia.android.scripts.gae.json.AndroidActivityRequests.ActivityRequest.LatestVersion +import org.oppia.android.scripts.gae.json.AndroidActivityRequests.ActivityRequest.Localized +import org.oppia.android.scripts.gae.json.AndroidActivityRequests.ActivityRequest.NonLocalized +import retrofit2.Call +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.io.File +import java.lang.reflect.Type +import java.util.concurrent.TimeUnit + +private typealias ActReq = ActivityRequest +private typealias AndroidActReqs = AndroidActivityRequests +private typealias VersionedStructures = List> + +class AndroidActivityHandlerService( + private val apiSecret: String, + private val baseUrl: String, + private val cacheDir: File?, + private val forceCacheLoad: Boolean, + private val dispatcher: CoroutineDispatcher +) { + private val memoizedRawResponses = mutableMapOf() + + private val httpClient by lazy { + OkHttpClient.Builder().apply { + addInterceptor(AuthorizationSecretAdderNetworkInterceptor(apiSecret)) + addInterceptor(JsonPrefixRemoverNetworkInterceptor(memoizedRawResponses)) + readTimeout(/* timeout = */ 5, /* unit = */ TimeUnit.MINUTES) + retryOnConnectionFailure(true) + }.build() + } + private val moshi by lazy { MoshiFactory.createMoshi() } + private val retrofit by lazy { + Retrofit.Builder().apply { + baseUrl(baseUrl) + client(httpClient) + addConverterFactory(MoshiRequestsStringConverterFactory(moshi)) + addConverterFactory(MoshiConverterFactory.create(moshi)) + }.build() + } + private val apiService by lazy { retrofit.create(AndroidActivityEndpointApi::class.java) } + + fun fetchLatestClassroomAsync(name: String): Deferred> { + return fetchLatestFromServiceAsync( + type = "classroom", + id = name, + fetch = apiService::fetchLatestClassroom, + retrieveStructureVersion = null // Classroom versions aren't exposed in the API. + ) + } + + fun fetchLatestExplorationAsync(id: String): Deferred> { + return fetchLatestFromServiceAsync( + type = "exploration", + id = id, + fetch = apiService::fetchLatestExploration, + retrieveStructureVersion = GaeExploration::version + ) + } + + fun fetchExplorationByVersionsAsync( + id: String, + versions: List + ): Deferred>> { + return fetchVersionedFromServiceAsync( + type = "exploration", + id = id, + versions = versions, + createRequest = ::NonLocalized, + createRequests = AndroidActivityRequests::NonLocalized, + fetch = apiService::fetchExplorationByVersion, + retrieveStructureVersion = GaeExploration::version + ) + } + + fun fetchLatestStoryAsync(id: String): Deferred> { + return fetchLatestFromServiceAsync( + type = "story", + id = id, + fetch = apiService::fetchLatestStory, + retrieveStructureVersion = GaeStory::version + ) + } + + fun fetchStoryByVersionsAsync( + id: String, + versions: List + ): Deferred>> { + return fetchVersionedFromServiceAsync( + type = "story", + id = id, + versions = versions, + createRequest = ::NonLocalized, + createRequests = AndroidActivityRequests::NonLocalized, + fetch = apiService::fetchStoryByVersion, + retrieveStructureVersion = GaeStory::version + ) + } + + fun fetchLatestConceptCardAsync(skillId: String): Deferred> { + return fetchLatestFromServiceAsync( + type = "concept_card", + id = skillId, + fetch = apiService::fetchLatestConceptCard, + retrieveStructureVersion = GaeSkill::version + ) + } + + fun fetchConceptCardByVersionsAsync( + skillId: String, + versions: List + ): Deferred>> { + return fetchVersionedFromServiceAsync( + type = "concept_card", + id = skillId, + versions = versions, + createRequest = ::NonLocalized, + createRequests = AndroidActivityRequests::NonLocalized, + fetch = apiService::fetchConceptCardByVersion, + retrieveStructureVersion = GaeSkill::version + ) + } + + fun fetchLatestRevisionCardAsync( + topicId: String, + subtopicIndex: Int + ): Deferred> { + return fetchLatestFromServiceAsync( + type = "revision_card", + id = "$topicId-$subtopicIndex", + fetch = apiService::fetchLatestRevisionCard, + retrieveStructureVersion = GaeSubtopicPage::version + ) + } + + fun fetchRevisionCardByVersionsAsync( + topicId: String, + subtopicIndex: Int, + versions: List + ): Deferred>> { + return fetchVersionedFromServiceAsync( + type = "revision_card", + id = "$topicId-$subtopicIndex", + versions = versions, + createRequest = ::NonLocalized, + createRequests = AndroidActivityRequests::NonLocalized, + fetch = apiService::fetchRevisionCardByVersion, + retrieveStructureVersion = GaeSubtopicPage::version + ) + } + + fun fetchLatestTopicAsync(id: String): Deferred> { + return fetchLatestFromServiceAsync( + type = "topic", + id = id, + fetch = apiService::fetchLatestTopic, + retrieveStructureVersion = GaeTopic::version + ) + } + + fun fetchTopicByVersionsAsync( + id: String, + versions: List + ): Deferred>> { + return fetchVersionedFromServiceAsync( + type = "topic", + id = id, + versions = versions, + createRequest = ::NonLocalized, + createRequests = AndroidActivityRequests::NonLocalized, + fetch = apiService::fetchTopicByVersion, + retrieveStructureVersion = GaeTopic::version + ) + } + + fun fetchExplorationTranslationsAsync( + explorationId: String, + explorationVersion: Int, + languageCode: String + ): Deferred> { + val fullFetch = fetchVersionedFromServiceAsync( + type = "exploration_translations", + id = explorationId, + versions = listOf(explorationVersion), + createRequest = { id, version -> Localized(id, version, languageCode) }, + createRequests = AndroidActivityRequests::Localized, + fetch = apiService::fetchExplorationTranslations, + retrieveStructureVersion = null // There's no version passed with translations. + ) + return CoroutineScope(dispatcher).async { fullFetch.await().single() } + } + + private fun Call>.resolveAsyncVersionsAsync( + expectedId: String, + expectedVersions: List + ): Deferred> { + val expectedIdsAndVersions = expectedVersions.map { expectedId to it } + // Use the I/O dispatcher for blocking HTTP operations (since it's designed to handle blocking + // operations that might otherwise stall a coroutine dispatcher). + return CoroutineScope(dispatcher).async { + val responses = resolveSync() + val receivedIdsAndVersions = + responses.mapTo(mutableSetOf()) { versioned -> versioned.id to versioned.version } + val missingIds = expectedIdsAndVersions - receivedIdsAndVersions + val extraIds = receivedIdsAndVersions - expectedIdsAndVersions.toSet() + check(missingIds.isEmpty()) { + "Missing ID/versions in response: $missingIds. Received: $receivedIdsAndVersions." + } + check(extraIds.isEmpty()) { + "Received extra ID/versions in response: $missingIds. Received: $receivedIdsAndVersions." + } + + // Return the structures in the order of the input IDs/versions map. + val associatedMap = responses.associateBy { versioned -> versioned.id to versioned.version } + return@async expectedIdsAndVersions.map { associatedMap.getValue(it) } + } + } + + private fun Call>.resolveAsync( + expectedId: String + ): Deferred> { + return CoroutineScope(dispatcher).async { + val responses = resolveSync() + checkNotNull(responses.singleOrNull { it.id == expectedId }) { + "Missing expected ID $expectedId from responses: $responses.".redact() + } + } + } + + private suspend fun Call>.resolveSync(): VersionedStructures { + return withContext(Dispatchers.IO) { + try { + val result = execute() + return@withContext if (result.isSuccessful) { + checkNotNull(result.body()) { + "Failed to receive body for request: ${request()}.".redact() + } + } else error("Failed to call: ${request()}. Encountered failure:\n$result.".redact()) + } catch (exception: Exception) { + val metadata = RequestMetadata(request().method, request().url.toUrl().toExternalForm()) + val responseBodyText = memoizedRawResponses[metadata] + throw IllegalStateException( + "Failed to call: ${request()}. Response body:\n\n$responseBodyText".redact(), exception + ) + } + } + } + + private fun String.redact(): String = replace(apiSecret, "") + + private inline fun fetchLatestFromServiceAsync( + type: String, + id: String, + crossinline fetch: (AndroidActivityRequests.Latest) -> Call>>, + noinline retrieveStructureVersion: ((T) -> Int)? + ): Deferred> { + return CoroutineScope(dispatcher).async { + if (forceCacheLoad && cacheDir != null) { + // Try to load latest from the local directory, first. + val expectedPrefix = computeFileNamePrefix(type, id, version = "") + val mostRecentVersion = cacheDir.listFiles()?.filter { + it.extension == "json" && it.nameWithoutExtension.startsWith(expectedPrefix) + }?.maxOfOrNull { it.nameWithoutExtension.substringAfter(expectedPrefix).toInt() } + if (mostRecentVersion != null) { + return@async checkNotNull(tryLoadFromCache(type, NonLocalized(id, mostRecentVersion))) { + "Something went wrong when trying to fetch latest $type from disk: $id." + } + } + } + + val request = AndroidActivityRequests.Latest(LatestVersion(id)) + val remoteStructure = fetch(request).resolveAsync(id).await() + // Ensure that the returned structure has the correct version. + val updatedStructure = if (retrieveStructureVersion != null) { + remoteStructure.copy(version = retrieveStructureVersion(remoteStructure.payload)) + } else remoteStructure + maybeSaveToCache(type, NonLocalized(id, updatedStructure.expectedVersion), updatedStructure) + return@async updatedStructure + } + } + + private inline fun fetchVersionedFromServiceAsync( + type: String, + id: String, + versions: List, + crossinline createRequest: (String, Int) -> R, + crossinline createRequests: (List) -> RS, + crossinline fetch: (RS) -> Call>>, + noinline retrieveStructureVersion: ((T) -> Int)? + ): Deferred>> { + require(versions.all { it >= 1 }) { "Versions must be >= 1." } + require(versions.toSet().size == versions.size) { "Expected requested versions to be unique." } + return CoroutineScope(dispatcher).async { + val requests = versions.map { createRequest(id, it) } + val localStructures = requests.map { tryLoadFromCache(type, it) } + val requestsRequiringRemoteFetching = + localStructures.withIndex().filter { (_, structure) -> + structure == null + }.map { (index, _) -> index to requests[index] } + val reqsCol = createRequests(requestsRequiringRemoteFetching.map { (_, req) -> req }) + val fetchResult = if (reqsCol.requests.isNotEmpty()) { + // Only fetch if there are versions to retrieve. + fetch(reqsCol).resolveAsyncVersionsAsync(id, versions).await().map { structure -> + // Ensure that the returned structures have the correct remote versions (since the web + // controller isn't consistent in when it provides a version). + if (retrieveStructureVersion != null) { + structure.copy(version = retrieveStructureVersion(structure.payload)) + } else structure + } + } else emptyList() + val remoteStructures = fetchResult.withIndex().associate { (index, structure) -> + requestsRequiringRemoteFetching[index].first to structure + } + // Merge locally and remotely fetched structures, then try to save everything to disk. + return@async localStructures.mapIndexed { index, structure -> + structure ?: remoteStructures.getValue(index) + }.also { allStructures -> + allStructures.forEachIndexed { index, structure -> + maybeSaveToCache(type, requests[index], structure) + } + } + } + } + + private suspend inline fun tryLoadFromCache( + type: String, + request: ActivityRequest + ): VersionedStructure? { + val expectedFilename = request.convertToFileName(type) + val baseCacheDir = cacheDir ?: return null + return withContext(Dispatchers.IO) { + File(baseCacheDir, expectedFilename).takeIf(File::exists)?.let { file -> + val buffer = Buffer().also { file.inputStream().use(it::readFrom) } + val activityType = Types.newParameterizedType(VersionedStructure::class.java, T::class.java) + checkNotNull(moshi.adapter>(activityType).fromJson(buffer)) { + "Failed to parse JSON file: ${file.path}." + } + } + } + } + + private suspend inline fun maybeSaveToCache( + type: String, + request: ActivityRequest, + structure: VersionedStructure + ) { + val expectedFilename = request.convertToFileName(type) + val baseCacheDir = cacheDir ?: return + withContext(Dispatchers.IO) { + val expectedFile = File(baseCacheDir, expectedFilename) + if (!expectedFile.exists()) { + // Only write the saved file if it doesn't already exist, and if the structure successfully + // converts to JSON. + val buffer = Buffer().also { + moshi.adapter>( + Types.newParameterizedType(VersionedStructure::class.java, T::class.java) + ).indent(" ").toJson(it, structure) + } + expectedFile.outputStream().use(buffer::writeTo) + } + } + } + + private data class RequestMetadata(val method: String, val url: String) + + /** + * Interceptor on top of Retrofit to modify requests and response. + * + * The interceptor removes the [XSSI_PREFIX] from every Oppia backend response to produce valid + * JSON. + */ + private class JsonPrefixRemoverNetworkInterceptor( + private val memoizedRawResponses: MutableMap + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val originalResponse = chain.proceed(request) + return originalResponse.newBuilder().apply { + body(originalResponse.body?.stripXssiPrefix(request)) + }.build() + } + + private fun ResponseBody.stripXssiPrefix(request: Request): ResponseBody { + val textBody = string().removePrefix(XSSI_PREFIX).trimStart() + val metadata = RequestMetadata(request.method, request.url.toUrl().toExternalForm()) + memoizedRawResponses[metadata] = textBody + return textBody.toResponseBody(contentType()) + } + + private companion object { + private const val XSSI_PREFIX = ")]}'" + } + } + + private class AuthorizationSecretAdderNetworkInterceptor( + private val apiSecret: String + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder().apply { + // Augment the request's headers with the authorization token. + addHeader("X-ApiKey", apiSecret) + }.build() + ) + } + } + + // This is loosely based on MoshiConverterFactory, though it's set up to generate compact JSON + // strings for GET requests (since MoshiConverterFactory doesn't support this directly). + private class MoshiRequestsStringConverterFactory(moshi: Moshi) : Converter.Factory() { + private val adapter by lazy { moshi.adapter(AndroidActivityRequests::class.java) } + + override fun stringConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter<*, String>? { + return if (AndroidActivityRequests::class.java.isAssignableFrom(type.rawType)) { + Converter { adapter.toJson(it as AndroidActivityRequests) } + } else null + } + } + + private companion object { + private fun ActivityRequest.convertToFileName(type: String): String { + return when (this) { + is LatestVersion -> error("Cannot load/save latest versions of structures.") + is NonLocalized -> "${computeFileNamePrefix(type, id, version.toString())}.json" + is Localized -> + "${computeFileNamePrefix(type, id, version.toString())}_lang-$languageCode.json" + } + } + + private fun computeFileNamePrefix(type: String, id: String, version: String): String = + "${type}_id-${id}_ver-$version" + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityRequests.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityRequests.kt new file mode 100644 index 00000000000..1e629cf362b --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/AndroidActivityRequests.kt @@ -0,0 +1,84 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +@JsonClass(generateAdapter = false) +sealed class AndroidActivityRequests { + abstract val requests: List + + data class Latest(val latestVersion: ActivityRequest.LatestVersion) : AndroidActivityRequests() { + override val requests = listOf(latestVersion) + } + + data class NonLocalized( + override val requests: List + ) : AndroidActivityRequests() + + data class Localized( + override val requests: List + ) : AndroidActivityRequests() + + class Adapter { + @FromJson + fun parseFromJson( + @Suppress("UNUSED_PARAMETER") jsonReader: JsonReader + ): AndroidActivityRequests = error("Conversion from JSON is not supported.") + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + androidActivityRequests: AndroidActivityRequests, + activityRequestAdapter: JsonAdapter + ) { + jsonWriter.beginArray() + androidActivityRequests.requests.forEach { activityRequestAdapter.toJson(jsonWriter, it) } + jsonWriter.endArray() + } + } + + @JsonClass(generateAdapter = false) + sealed class ActivityRequest { + @JsonClass(generateAdapter = true) + data class LatestVersion(@Json(name = "id") val id: String) : ActivityRequest() + + @JsonClass(generateAdapter = true) + data class NonLocalized( + @Json(name = "id") val id: String, + @Json(name = "version") val version: Int + ) : ActivityRequest() + + @JsonClass(generateAdapter = true) + data class Localized( + @Json(name = "id") val id: String, + @Json(name = "version") val version: Int, + @Json(name = "language_code") val languageCode: String + ) : ActivityRequest() + + class Adapter { + @FromJson + fun parseFromJson(@Suppress("UNUSED_PARAMETER") jsonReader: JsonReader): ActivityRequest = + error("Conversion from JSON is not supported.") + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + activityRequest: ActivityRequest, + latestVersionAdapter: JsonAdapter, + nonLocalizedAdapter: JsonAdapter, + localizedAdapter: JsonAdapter + ) { + when (activityRequest) { + is LatestVersion -> latestVersionAdapter.toJson(jsonWriter, activityRequest) + is NonLocalized -> nonLocalizedAdapter.toJson(jsonWriter, activityRequest) + is Localized -> localizedAdapter.toJson(jsonWriter, activityRequest) + } + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/gae/json/BUILD.bazel new file mode 100644 index 00000000000..f5eca295536 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/BUILD.bazel @@ -0,0 +1,83 @@ +""" +Library for providing the JSON model definitions & endpoint details for Oppia web's Google App +Engine Android-specific endpoints, particularly for lesson downloads. +""" + +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") + +# TODO: Split this up. +kt_jvm_library( + name = "model", + testonly = True, + srcs = [ + "AndroidActivityRequests.kt", + "GaeAnswerGroup.kt", + "GaeClassroom.kt", + "GaeCustomizationArgValue.kt", + "GaeEntityTranslations.kt", + "GaeExploration.kt", + "GaeHint.kt", + "GaeInteractionCustomizationArgsMap.kt", + "GaeInteractionInstance.kt", + "GaeInteractionObject.kt", + "GaeMisconception.kt", + "GaeOutcome.kt", + "GaeParamChange.kt", + "GaeParamCustomizationArgs.kt", + "GaeParamSpec.kt", + "GaeRecordedVoiceovers.kt", + "GaeRubric.kt", + "GaeRuleSpec.kt", + "GaeSkill.kt", + "GaeSkillContents.kt", + "GaeSolution.kt", + "GaeState.kt", + "GaeStory.kt", + "GaeStoryContents.kt", + "GaeStoryNode.kt", + "GaeStoryReference.kt", + "GaeSubtitledHtml.kt", + "GaeSubtitledUnicode.kt", + "GaeSubtopic.kt", + "GaeSubtopicPage.kt", + "GaeSubtopicPageContents.kt", + "GaeTopic.kt", + "GaeTranslatableContentFormat.kt", + "GaeTranslatedContent.kt", + "GaeVoiceover.kt", + "GaeWorkedExample.kt", + "GaeWrittenTranslation.kt", + "GaeWrittenTranslations.kt", + "JsonReaderExtensions.kt", + "MoshiFactory.kt", + "SubtitledText.kt", + "TypeResolutionContext.kt", + "VersionedStructure.kt", + ], + visibility = [ + "//scripts/src/java/org/oppia/android/scripts/gae:__subpackages__", + ], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/gae/proto:extra_exploration_definitions_java_proto", + "//third_party:moshi", + "//third_party:oppia_proto_api_java_protos", + ], +) + +kt_jvm_library( + name = "api", + testonly = True, + srcs = [ + "AndroidActivityEndpointApi.kt", + "AndroidActivityHandlerService.kt", + ], + visibility = [ + "//scripts/src/java/org/oppia/android/scripts/gae:__subpackages__", + ], + deps = [ + ":model", + "//third_party:com_squareup_retrofit2_converter-moshi", + "//third_party:com_squareup_retrofit2_retrofit", + "//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-core", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeAnswerGroup.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeAnswerGroup.kt new file mode 100644 index 00000000000..d8ffd17b450 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeAnswerGroup.kt @@ -0,0 +1,19 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeAnswerGroup( + @Json(name = "rule_specs") val ruleSpecs: List, + @Json(name = "outcome") val outcome: GaeOutcome, + @Json(name = "training_data") + @GaeInteractionObject.SolutionInteractionAnswer // TODO: Document that this is wrong (and can fail if Oppia ever uses it). + val trainingData: List<@JvmSuppressWildcards GaeInteractionObject>, + @Json(name = "tagged_skill_misconception_id") val taggedSkillMisconceptionId: String? +) { + fun computeReferencedSkillIds(): List { + val referencedSkillId = taggedSkillMisconceptionId?.substringBefore('-') + return listOfNotNull(referencedSkillId, outcome.missingPrerequisiteSkillId) + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeClassroom.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeClassroom.kt new file mode 100644 index 00000000000..c932942c289 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeClassroom.kt @@ -0,0 +1,13 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeClassroom( + @Json(name = "name") val name: String, + @Json(name = "url_fragment") val urlFragment: String, + @Json(name = "topic_ids") val topicIds: List, + @Json(name = "course_details") val courseDetails: String, + @Json(name = "topic_list_intro") val topicListIntro: String +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeCustomizationArgValue.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeCustomizationArgValue.kt new file mode 100644 index 00000000000..259fb9da963 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeCustomizationArgValue.kt @@ -0,0 +1,261 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.oppia.android.scripts.gae.proto.CustomizationArgValue +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.ALGEBRAIC_EXPRESSION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.CONTINUE_INSTANCE +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.DRAG_AND_DROP_SORT_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.END_EXPLORATION +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.FRACTION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.IMAGE_CLICK_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.INTERACTIONTYPE_NOT_SET +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.ITEM_SELECTION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.MATH_EQUATION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.MULTIPLE_CHOICE_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.NUMERIC_EXPRESSION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.NUMERIC_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.RATIO_EXPRESSION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.TEXT_INPUT + +// TODO: Mention parsing this requires setting customization key name & interaction type in the parsing context. +sealed class GaeCustomizationArgValue { + // TODO: Remove CustomizationArgValue. + protected abstract val valueType: CustomizationArgValue.ValueTypeCase + + protected open fun populateValue(builder: CustomizationArgValue.Builder) {} + + data class SingleInteger(val value: Int) : GaeCustomizationArgValue() { + override val valueType = CustomizationArgValue.ValueTypeCase.INTEGER + + override fun populateValue(builder: CustomizationArgValue.Builder) { + builder.integer = value + } + } + + data class SingleBoolean(val value: Boolean) : GaeCustomizationArgValue() { + override val valueType = CustomizationArgValue.ValueTypeCase.BOOLEAN + + override fun populateValue(builder: CustomizationArgValue.Builder) { + builder.boolean = value + } + } + + data class SubtitledUnicode(val value: GaeSubtitledUnicode) : GaeCustomizationArgValue() { + override val valueType = CustomizationArgValue.ValueTypeCase.SUBTITLED_TEXT_DTO + } + + data class StringList(val value: List) : GaeCustomizationArgValue() { + override val valueType = CustomizationArgValue.ValueTypeCase.STRING_LIST + } + + data class SubtitledTextList(val value: List) : GaeCustomizationArgValue() { + override val valueType = CustomizationArgValue.ValueTypeCase.SUBTITLED_TEXT_LIST + } + + @JsonClass(generateAdapter = true) + data class GaeImageWithRegions( + @Json(name = "imagePath") val imagePath: String, + @Json(name = "labeledRegions") val labeledRegions: List + ) : GaeCustomizationArgValue() { + override val valueType = CustomizationArgValue.ValueTypeCase.IMAGE_WITH_REGIONS_DTO + + @JsonClass(generateAdapter = true) + data class GaeLabeledRegion( + @Json(name = "label") val label: String, + @Json(name = "region") val region: GaeImageRegion, + @Json(name = "contentDescription") val contentDescription: String? + ) { + @JsonClass(generateAdapter = true) + data class GaeImageRegion( + @Json(name = "regionType") val regionType: String, + @Json(name = "area") val area: GaeNormalizedRectangle2d + ) + + @JsonClass(generateAdapter = false) + data class GaeNormalizedRectangle2d(val items: List>) { + class Adapter { + @FromJson + fun parseFromJson(jsonReader: JsonReader): GaeNormalizedRectangle2d { + val (upperLeftPoint, lowerRightPoint) = jsonReader.nextArray { + jsonReader.nextArray(jsonReader::nextDouble) + } + val (upperLeftX, upperLeftY) = upperLeftPoint + val (lowerRightX, lowerRightY) = lowerRightPoint + return GaeNormalizedRectangle2d( + listOf(listOf(upperLeftX, upperLeftY), listOf(lowerRightX, lowerRightY)) + ) + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeNormalizedRectangle2d: GaeNormalizedRectangle2d + ) { + val (upperLeftPoint, lowerRightPoint) = gaeNormalizedRectangle2d.items + val (upperLeftX, upperLeftY) = upperLeftPoint + val (lowerRightX, lowerRightY) = lowerRightPoint + + jsonWriter.beginArray() + jsonWriter.beginArray().value(upperLeftX).value(upperLeftY).endArray() + jsonWriter.beginArray().value(lowerRightX).value(lowerRightY).endArray() + jsonWriter.endArray() + } + } + } + } + } + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + subtitledHtmlAdapter: JsonAdapter, + subtitledUnicodeAdapter: JsonAdapter, + imageWithRegionsAdapter: JsonAdapter + ): GaeCustomizationArgValue { + val key = typeResolutionContext.expectedCustomizationArgKeyName + return when (val interactionType = typeResolutionContext.expectedInteractionType) { + CONTINUE_INSTANCE -> when (key) { + "buttonText" -> jsonReader.nextSubtitledUnicodeArgValue(subtitledUnicodeAdapter) + else -> null + } + FRACTION_INPUT -> when (key) { + "requireSimplestForm", "allowImproperFraction", "allowNonzeroIntegerPart" -> + jsonReader.nextBooleanArgValue() + "customPlaceholder" -> jsonReader.nextSubtitledUnicodeArgValue(subtitledUnicodeAdapter) + else -> null + } + ITEM_SELECTION_INPUT -> when (key) { + "minAllowableSelectionCount", "maxAllowableSelectionCount" -> + jsonReader.nextIntArgValue() + "choices" -> jsonReader.nextSubtitledHtmlListArgValue(subtitledHtmlAdapter) + else -> null + } + MULTIPLE_CHOICE_INPUT -> when (key) { + "choices" -> jsonReader.nextSubtitledHtmlListArgValue(subtitledHtmlAdapter) + "showChoicesInShuffledOrder" -> jsonReader.nextBooleanArgValue() + else -> null + } + NUMERIC_INPUT -> when (key) { + "requireNonnegativeInput" -> jsonReader.nextBooleanArgValue() + else -> null + } + TEXT_INPUT -> when (key) { + "placeholder" -> jsonReader.nextSubtitledUnicodeArgValue(subtitledUnicodeAdapter) + "rows" -> jsonReader.nextIntArgValue() + "catchMisspellings" -> jsonReader.nextBooleanArgValue() + else -> null + } + DRAG_AND_DROP_SORT_INPUT -> when (key) { + "choices" -> jsonReader.nextSubtitledHtmlListArgValue(subtitledHtmlAdapter) + "allowMultipleItemsInSamePosition" -> jsonReader.nextBooleanArgValue() + else -> null + } + IMAGE_CLICK_INPUT -> when (key) { + "imageAndRegions" -> jsonReader.nextImageWithRegions(imageWithRegionsAdapter) + "highlightRegionsOnHover" -> jsonReader.nextBooleanArgValue() + else -> null + } + RATIO_EXPRESSION_INPUT -> when (key) { + "placeholder" -> jsonReader.nextSubtitledUnicodeArgValue(subtitledUnicodeAdapter) + "numberOfTerms" -> jsonReader.nextIntArgValue() + else -> null + } + ALGEBRAIC_EXPRESSION_INPUT, MATH_EQUATION_INPUT -> when (key) { + "allowedVariables" -> jsonReader.nextStringList() + "useFractionForDivision" -> jsonReader.nextBooleanArgValue() + else -> null + } + NUMERIC_EXPRESSION_INPUT -> when (key) { + "placeholder" -> jsonReader.nextSubtitledUnicodeArgValue(subtitledUnicodeAdapter) + "useFractionForDivision" -> jsonReader.nextBooleanArgValue() + else -> null + } + END_EXPLORATION -> when (key) { + "recommendedExplorationIds" -> jsonReader.nextStringList() + else -> null + } + INTERACTIONTYPE_NOT_SET -> error("Interaction has no customization args: $interactionType.") + } ?: error( + "${typeResolutionContext.currentInteractionType} interaction doesn't expect" + + " customization arg with key: $key." + ) + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeCustomizationArgValue: GaeCustomizationArgValue, + subtitledUnicodeAdapter: JsonAdapter, + subtitledHtmlAdapter: JsonAdapter, + imageWithRegionsAdapter: JsonAdapter + ) { + when (gaeCustomizationArgValue) { + is GaeImageWithRegions -> + jsonWriter.value(gaeCustomizationArgValue, imageWithRegionsAdapter) + is SingleBoolean -> jsonWriter.value(gaeCustomizationArgValue) + is SingleInteger -> jsonWriter.value(gaeCustomizationArgValue) + is StringList -> jsonWriter.value(gaeCustomizationArgValue) + is SubtitledTextList -> jsonWriter.value(gaeCustomizationArgValue, subtitledHtmlAdapter) + is SubtitledUnicode -> jsonWriter.value(gaeCustomizationArgValue, subtitledUnicodeAdapter) + } + } + + private companion object { + private fun JsonReader.nextBooleanArgValue() = SingleBoolean(nextBoolean()) + + private fun JsonReader.nextIntArgValue() = SingleInteger(nextInt()) + + private fun JsonReader.nextSubtitledUnicodeArgValue( + subtitledUnicodeAdapter: JsonAdapter + ) = SubtitledUnicode(nextCustomValue(subtitledUnicodeAdapter)) + + private fun JsonReader.nextSubtitledHtmlListArgValue( + subtitledHtmlAdapter: JsonAdapter + ) = SubtitledTextList(nextArray { nextCustomValue(subtitledHtmlAdapter) }) + + private fun JsonReader.nextImageWithRegions( + imageWithRegionsAdapter: JsonAdapter + ) = nextCustomValue(imageWithRegionsAdapter) + + private fun JsonReader.nextStringList() = StringList(nextArray(::nextString)) + + private fun JsonWriter.value(boolean: SingleBoolean): JsonWriter = value(boolean.value) + + private fun JsonWriter.value(int: SingleInteger): JsonWriter = value(int.value.toLong()) + + private fun JsonWriter.value( + subtitledUnicode: SubtitledUnicode, + subtitledUnicodeAdapter: JsonAdapter + ): JsonWriter = this.also { subtitledUnicodeAdapter.toJson(it, subtitledUnicode.value) } + + private fun JsonWriter.value( + subtitledHtml: GaeSubtitledHtml, + subtitledHtmlAdapter: JsonAdapter + ): JsonWriter = this.also { subtitledHtmlAdapter.toJson(it, subtitledHtml) } + + private fun JsonWriter.value( + subtitledTextList: SubtitledTextList, + subtitledHtmlAdapter: JsonAdapter + ): JsonWriter { + return beginArray().also { + subtitledTextList.value.forEach { value(it, subtitledHtmlAdapter) } + }.endArray() + } + + private fun JsonWriter.value( + imageWithRegions: GaeImageWithRegions, + imageWithRegionsAdapter: JsonAdapter + ): JsonWriter = this.also { imageWithRegionsAdapter.toJson(it, imageWithRegions) } + + private fun JsonWriter.value(strs: StringList): JsonWriter = + beginArray().also { strs.value.forEach(::value) }.endArray() + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeEntityTranslations.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeEntityTranslations.kt new file mode 100644 index 00000000000..e9dd8127908 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeEntityTranslations.kt @@ -0,0 +1,37 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +@JsonClass(generateAdapter = false) +data class GaeEntityTranslations(val translations: Map) { + object Adapter { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + gaeTranslatedContentAdapter: JsonAdapter + ): GaeEntityTranslations { + return GaeEntityTranslations( + jsonReader.nextObject { jsonReader.nextCustomValue(gaeTranslatedContentAdapter) } + ) + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeEntityTranslations: GaeEntityTranslations, + gaeTranslatedContentAdapter: JsonAdapter + ) { + jsonWriter.beginObject() + for ((languageCode, gaeTranslatedContent) in gaeEntityTranslations.translations) { + jsonWriter.name(languageCode) + gaeTranslatedContentAdapter.toJson(jsonWriter, gaeTranslatedContent) + } + jsonWriter.endObject() + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeExploration.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeExploration.kt new file mode 100644 index 00000000000..e5419d07454 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeExploration.kt @@ -0,0 +1,28 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeExploration( + @Json(name = "id") val id: String, + @Json(name = "title") val title: String, + @Json(name = "category") val category: String, + @Json(name = "author_notes") val author_notes: String, + @Json(name = "blurb") val blurb: String, + @Json(name = "states_schema_version") val statesSchemaVersion: Int, + @Json(name = "init_state_name") val initStateName: String, + @Json(name = "language_code") val languageCode: String, + @Json(name = "objective") val objective: String, + @Json(name = "param_changes") val paramChanges: List, + @Json(name = "param_specs") val paramSpecs: Map, + @Json(name = "tags") val tags: List, + @Json(name = "auto_tts_enabled") val autoTtsEnabled: Boolean, + @Json(name = "next_content_id_index") val nextContentIdIndex: Int, + @Json(name = "edits_allowed") val editsAllowed: Boolean, + @Json(name = "states") val states: Map, + @Json(name = "version") val version: Int +) { + fun computeDirectlyReferencedSkillIds(): Set = + states.values.flatMap { it.computeReferencedSkillIds() }.toSet() +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeHint.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeHint.kt new file mode 100644 index 00000000000..b826aa5a1db --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeHint.kt @@ -0,0 +1,7 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeHint(@Json(name = "hint_content") val hintContent: GaeSubtitledHtml) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionCustomizationArgsMap.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionCustomizationArgsMap.kt new file mode 100644 index 00000000000..a4e63850dc1 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionCustomizationArgsMap.kt @@ -0,0 +1,52 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +@JsonClass(generateAdapter = false) +data class GaeInteractionCustomizationArgsMap( + val customizationArgs: Map +) { + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + customizationArgValueAdapter: JsonAdapter + ): GaeInteractionCustomizationArgsMap { + val customizationArgs = jsonReader.nextObject { key -> + typeResolutionContext.currentCustomizationArgKeyName = key + jsonReader.nextObject { expectedValueKey -> + check(expectedValueKey == "value") { + "Only 'value' is expected for the customization args value map, encountered:" + + " $expectedValueKey." + } + jsonReader.nextCustomValue(customizationArgValueAdapter) + }.values.single() + } + // Reset argument names (since none are being parsed anymore). + typeResolutionContext.currentCustomizationArgKeyName = null + return GaeInteractionCustomizationArgsMap(customizationArgs) + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeInteractionCustomizationArgsMap: GaeInteractionCustomizationArgsMap, + customizationArgValueAdapter: JsonAdapter + ) { + jsonWriter.beginObject() + gaeInteractionCustomizationArgsMap.customizationArgs.forEach { (key, arg) -> + jsonWriter.name(key) + jsonWriter.beginObject() + jsonWriter.name("value") + customizationArgValueAdapter.toJson(jsonWriter, arg) + jsonWriter.endObject() + } + jsonWriter.endObject() + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionInstance.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionInstance.kt new file mode 100644 index 00000000000..aff4427d9f2 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionInstance.kt @@ -0,0 +1,107 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase + +@JsonClass(generateAdapter = false) +data class GaeInteractionInstance( + val id: String?, + val customizationArgs: GaeInteractionCustomizationArgsMap, + val answerGroups: List, + val defaultOutcome: GaeOutcome?, + val confirmedUnclassifiedAnswers: List<@JvmSuppressWildcards GaeInteractionObject>, + val hints: List, + val solution: GaeSolution? +) { + fun computeReferencedSkillIds(): List { + return answerGroups.flatMap { it.computeReferencedSkillIds() } + + listOfNotNull(defaultOutcome?.missingPrerequisiteSkillId) + } + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + parsableInteractionInstanceAdapter: JsonAdapter + ): GaeInteractionInstance { + typeResolutionContext.currentInteractionType = jsonReader.peekInteractionId() + return jsonReader.nextCustomValue(parsableInteractionInstanceAdapter).also { + // Reset the interaction type now that parsing has completed. + typeResolutionContext.currentInteractionType = null + }.convertToGaeObject() + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeInteractionInstance: GaeInteractionInstance, + parsableInteractionInstanceAdapter: JsonAdapter + ) { + val parsable = ParsableInteractionInstance( + id = gaeInteractionInstance.id, + customizationArgs = gaeInteractionInstance.customizationArgs, + answerGroups = gaeInteractionInstance.answerGroups, + defaultOutcome = gaeInteractionInstance.defaultOutcome, + confirmedUnclassifiedAnswers = gaeInteractionInstance.confirmedUnclassifiedAnswers, + hints = gaeInteractionInstance.hints, + solution = gaeInteractionInstance.solution + ) + parsableInteractionInstanceAdapter.toJson(jsonWriter, parsable) + } + } + + @JsonClass(generateAdapter = true) + data class ParsableInteractionInstance( + @Json(name = "id") val id: String?, + @Json(name = "customization_args") val customizationArgs: GaeInteractionCustomizationArgsMap, + @Json(name = "answer_groups") val answerGroups: List, + @Json(name = "default_outcome") val defaultOutcome: GaeOutcome?, + @Json(name = "confirmed_unclassified_answers") + @GaeInteractionObject.SolutionInteractionAnswer // TODO: Document that this is wrong (and can fail if Oppia ever uses it). + val confirmedUnclassifiedAnswers: List<@JvmSuppressWildcards GaeInteractionObject>, + @Json(name = "hints") val hints: List, + @Json(name = "solution") val solution: GaeSolution? + ) { + fun convertToGaeObject(): GaeInteractionInstance { + return GaeInteractionInstance( + id, customizationArgs, answerGroups, defaultOutcome, confirmedUnclassifiedAnswers, hints, + solution + ) + } + } + + private companion object { + private fun JsonReader.peekInteractionId(): InteractionTypeCase { + return peekJson().use { jsonReader -> + jsonReader.nextObject { + if (it == "id") jsonReader.nextString() else null + }["id"]?.let(::parseInteractionId) ?: error("Missing ID in interaction JSON object.") + } + } + + private fun parseInteractionId(id: String): InteractionTypeCase { + return when (id) { + "Continue" -> InteractionTypeCase.CONTINUE_INSTANCE + "FractionInput" -> InteractionTypeCase.FRACTION_INPUT + "ItemSelectionInput" -> InteractionTypeCase.ITEM_SELECTION_INPUT + "MultipleChoiceInput" -> InteractionTypeCase.MULTIPLE_CHOICE_INPUT + "NumericInput" -> InteractionTypeCase.NUMERIC_INPUT + "TextInput" -> InteractionTypeCase.TEXT_INPUT + "DragAndDropSortInput" -> InteractionTypeCase.DRAG_AND_DROP_SORT_INPUT + "ImageClickInput" -> InteractionTypeCase.IMAGE_CLICK_INPUT + "RatioExpressionInput" -> InteractionTypeCase.RATIO_EXPRESSION_INPUT + "NumericExpressionInput" -> InteractionTypeCase.NUMERIC_EXPRESSION_INPUT + "AlgebraicExpressionInput" -> InteractionTypeCase.ALGEBRAIC_EXPRESSION_INPUT + "MathEquationInput" -> InteractionTypeCase.MATH_EQUATION_INPUT + "EndExploration" -> InteractionTypeCase.END_EXPLORATION + else -> error("Unsupported interaction ID: $id.") + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionObject.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionObject.kt new file mode 100644 index 00000000000..8be914be6e3 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeInteractionObject.kt @@ -0,0 +1,391 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonQualifier +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.ALGEBRAIC_EXPRESSION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.CONTINUE_INSTANCE +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.DRAG_AND_DROP_SORT_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.END_EXPLORATION +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.FRACTION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.IMAGE_CLICK_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.INTERACTIONTYPE_NOT_SET +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.ITEM_SELECTION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.MATH_EQUATION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.MULTIPLE_CHOICE_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.NUMERIC_EXPRESSION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.NUMERIC_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.RATIO_EXPRESSION_INPUT +import org.oppia.proto.v1.structure.InteractionInstanceDto.InteractionTypeCase.TEXT_INPUT + +@JsonClass(generateAdapter = false) +sealed class GaeInteractionObject { + data class NormalizedString(val value: String) : GaeInteractionObject() + + data class MathExpression(val value: String) : GaeInteractionObject() + + data class SignedInt(val value: Int) : GaeInteractionObject() + + data class NonNegativeInt(val value: Int) : GaeInteractionObject() + + data class Real(val value: Double) : GaeInteractionObject() + + @JsonClass(generateAdapter = false) + data class TranslatableHtmlContentId(val contentId: String) : GaeInteractionObject() { + class Adapter { + @FromJson + fun parseFromJson(jsonReader: JsonReader): TranslatableHtmlContentId = + TranslatableHtmlContentId(jsonReader.nextString()) + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + translatableHtmlContentId: TranslatableHtmlContentId + ) { + jsonWriter.value(translatableHtmlContentId.contentId) + } + } + } + + @JsonClass(generateAdapter = false) + data class SetOfXlatableContentIds( + val contentIds: List + ) : GaeInteractionObject() { + class Adapter { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + translatableHtmlContentIdAdapter: JsonAdapter + ): SetOfXlatableContentIds { + val contentIds = jsonReader.nextArray { + jsonReader.nextCustomValue(translatableHtmlContentIdAdapter) + } + return SetOfXlatableContentIds(contentIds) + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + setOfXlatableContentIds: SetOfXlatableContentIds, + translatableHtmlContentIdAdapter: JsonAdapter + ) { + jsonWriter.beginArray() + setOfXlatableContentIds.contentIds.forEach { + translatableHtmlContentIdAdapter.toJson(jsonWriter, it) + } + jsonWriter.endArray() + } + } + } + + @JsonClass(generateAdapter = true) + data class TranslatableSetOfNormalizedString( + @Json(name = "contentId") val contentId: String?, + @Json(name = "normalizedStrSet") val normalizedStrSet: List + ) : GaeInteractionObject() + + @JsonClass(generateAdapter = true) + data class Fraction( + @Json(name = "isNegative") val isNegative: Boolean, + @Json(name = "wholeNumber") val wholeNumber: Int, + @Json(name = "numerator") val numerator: Int, + @Json(name = "denominator") val denominator: Int + ) : GaeInteractionObject() + + @JsonClass(generateAdapter = false) + data class SetsOfXlatableContentIds( + val sets: List + ) : GaeInteractionObject() { + class Adapter { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + setOfXlatableContentIdsAdapter: JsonAdapter + ): SetsOfXlatableContentIds { + val contentIdSets = jsonReader.nextArray { + jsonReader.nextCustomValue(setOfXlatableContentIdsAdapter) + } + return SetsOfXlatableContentIds(contentIdSets) + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + setsOfXlatableContentIds: SetsOfXlatableContentIds, + setOfXlatableContentIdsAdapter: JsonAdapter + ) { + jsonWriter.beginArray() + setsOfXlatableContentIds.sets.forEach { + setOfXlatableContentIdsAdapter.toJson(jsonWriter, it) + } + jsonWriter.endArray() + } + } + } + + @JsonClass(generateAdapter = false) + data class RatioExpression(val ratioComponents: List) : GaeInteractionObject() { + class Adapter { + @FromJson + fun parseFromJson(jsonReader: JsonReader): RatioExpression = + RatioExpression(jsonReader.nextArray(jsonReader::nextInt)) + + @ToJson + fun convertToJson(jsonWriter: JsonWriter, ratioExpression: RatioExpression) { + jsonWriter.beginArray() + ratioExpression.ratioComponents.forEach { jsonWriter.value(it.toLong()) } + jsonWriter.endArray() + } + } + } + + @Retention(AnnotationRetention.RUNTIME) + @JsonQualifier + annotation class SolutionInteractionAnswer + + @Retention(AnnotationRetention.RUNTIME) + @JsonQualifier + annotation class RuleInput + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @RuleInput + @FromJson + fun parseRuleInputObjectFromJson( + jsonReader: JsonReader, + setOfXlatableHtmlContentIdsAdapter: JsonAdapter, + fractionAdapter: JsonAdapter, + listSetsOfXlatableIdsAdapter: JsonAdapter, + ratioExpressionAdapter: JsonAdapter, + translatableStrSetAdapter: JsonAdapter, + translatableHtmlContentIdAdapter: JsonAdapter + ): GaeInteractionObject { + return when (val currentInteractionType = typeResolutionContext.expectedInteractionType) { + FRACTION_INPUT -> parseFractionInputJson(jsonReader, fractionAdapter) + ITEM_SELECTION_INPUT -> + parseItemSelectionInputJson(jsonReader, setOfXlatableHtmlContentIdsAdapter) + MULTIPLE_CHOICE_INPUT -> parseMultipleChoiceInputJson(jsonReader) + NUMERIC_INPUT -> parseNumericInputJson(jsonReader) + TEXT_INPUT -> parseTextInputJson(jsonReader, translatableStrSetAdapter) + DRAG_AND_DROP_SORT_INPUT -> { + parseDragAndDropSortInputJson( + jsonReader, listSetsOfXlatableIdsAdapter, translatableHtmlContentIdAdapter + ) + } + IMAGE_CLICK_INPUT -> parseImageClickInputJson(jsonReader) + RATIO_EXPRESSION_INPUT -> parseRatioExpressionInputJson(jsonReader, ratioExpressionAdapter) + ALGEBRAIC_EXPRESSION_INPUT -> parseAlgebraicExpressionInputInputJson(jsonReader) + MATH_EQUATION_INPUT -> parseMathEquationInputInputJson(jsonReader) + NUMERIC_EXPRESSION_INPUT -> parseNumericExpressionInputJson(jsonReader) + END_EXPLORATION, CONTINUE_INSTANCE, INTERACTIONTYPE_NOT_SET -> + error("Unsupported interaction: $currentInteractionType.") + } + } + + @ToJson + fun convertRuleInputToJson( + jsonWriter: JsonWriter, + @RuleInput gaeInteractionObject: GaeInteractionObject, + setOfXlatableHtmlContentIdsAdapter: JsonAdapter, + fractionAdapter: JsonAdapter, + listSetsOfXlatableIdsAdapter: JsonAdapter, + ratioExpressionAdapter: JsonAdapter, + translatableStrSetAdapter: JsonAdapter, + translatableHtmlContentIdAdapter: JsonAdapter + ) { + when (gaeInteractionObject) { + is Fraction -> fractionAdapter.toJson(jsonWriter, gaeInteractionObject) + is MathExpression -> jsonWriter.value(gaeInteractionObject.value) + is NonNegativeInt -> jsonWriter.value(gaeInteractionObject.value.toLong()) + is NormalizedString -> jsonWriter.value(gaeInteractionObject.value) + is RatioExpression -> ratioExpressionAdapter.toJson(jsonWriter, gaeInteractionObject) + is Real -> jsonWriter.value(gaeInteractionObject.value) + is SetOfXlatableContentIds -> + setOfXlatableHtmlContentIdsAdapter.toJson(jsonWriter, gaeInteractionObject) + is SetsOfXlatableContentIds -> + listSetsOfXlatableIdsAdapter.toJson(jsonWriter, gaeInteractionObject) + is SignedInt -> jsonWriter.value(gaeInteractionObject.value.toLong()) + is TranslatableHtmlContentId -> + translatableHtmlContentIdAdapter.toJson(jsonWriter, gaeInteractionObject) + is TranslatableSetOfNormalizedString -> + translatableStrSetAdapter.toJson(jsonWriter, gaeInteractionObject) + } + } + + @SolutionInteractionAnswer + @FromJson + fun parseSolutionFromJson( + jsonReader: JsonReader, + setOfXlatableHtmlContentIdsAdapter: JsonAdapter, + fractionAdapter: JsonAdapter, + listSetsOfXlatableIdsAdapter: JsonAdapter, + ratioExpressionAdapter: JsonAdapter + ): GaeInteractionObject { + return when (val currentInteractionType = typeResolutionContext.expectedInteractionType) { + FRACTION_INPUT -> jsonReader.nextCustomValue(fractionAdapter) + ITEM_SELECTION_INPUT -> jsonReader.nextCustomValue(setOfXlatableHtmlContentIdsAdapter) + MULTIPLE_CHOICE_INPUT -> NonNegativeInt(jsonReader.nextInt()) + NUMERIC_INPUT -> Real(jsonReader.nextDouble()) + TEXT_INPUT -> NormalizedString(jsonReader.nextString()) + DRAG_AND_DROP_SORT_INPUT -> jsonReader.nextCustomValue(listSetsOfXlatableIdsAdapter) + RATIO_EXPRESSION_INPUT -> jsonReader.nextCustomValue(ratioExpressionAdapter) + ALGEBRAIC_EXPRESSION_INPUT, MATH_EQUATION_INPUT, NUMERIC_EXPRESSION_INPUT -> + MathExpression(jsonReader.nextString()) + IMAGE_CLICK_INPUT, END_EXPLORATION, CONTINUE_INSTANCE, INTERACTIONTYPE_NOT_SET -> + error("Unsupported interaction: $currentInteractionType.") + } + } + + @ToJson + fun convertInteractionAnswerToJson( + jsonWriter: JsonWriter, + @SolutionInteractionAnswer gaeInteractionObject: GaeInteractionObject, + @RuleInput ruleInputObjectAdapter: JsonAdapter + ) = ruleInputObjectAdapter.toJson(jsonWriter, gaeInteractionObject) + + @SolutionInteractionAnswer + @FromJson + fun parseSolutionListFromJson( + jsonReader: JsonReader, + @SolutionInteractionAnswer solutionAdapter: JsonAdapter + ): List<@JvmSuppressWildcards GaeInteractionObject> { + return jsonReader.nextArray { jsonReader.nextCustomValue(solutionAdapter) } + } + + @ToJson + fun convertSolutionListToJson( + jsonWriter: JsonWriter, + @SolutionInteractionAnswer + gaeInteractionObjects: List<@JvmSuppressWildcards GaeInteractionObject>, + @SolutionInteractionAnswer solutionAdapter: JsonAdapter + ) { + jsonWriter.beginArray() + gaeInteractionObjects.forEach { solutionAdapter.toJson(jsonWriter, it) } + jsonWriter.endArray() + } + + private fun parseDragAndDropSortInputJson( + jsonReader: JsonReader, + listSetsOfXlatableIdsAdapter: JsonAdapter, + translatableHtmlContentIdAdapter: JsonAdapter + ): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + val currentInputName = typeResolutionContext.expectedRuleInputName + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "IsEqualToOrdering", "IsEqualToOrderingWithOneItemAtIncorrectPosition" -> + jsonReader.nextCustomValue(listSetsOfXlatableIdsAdapter) + "HasElementXAtPositionY" -> when (currentInputName) { + "x" -> jsonReader.nextCustomValue(translatableHtmlContentIdAdapter) + "y" -> NonNegativeInt(jsonReader.nextInt()) + else -> { + error( + "Unexpected param $currentInputName in rule type $currentRuleType " + + "for $currentInteractionType." + ) + } + } + "HasElementXBeforeElementY" -> jsonReader.nextCustomValue(translatableHtmlContentIdAdapter) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseFractionInputJson( + jsonReader: JsonReader, + fractionAdapter: JsonAdapter + ): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "IsExactlyEqualTo", "IsEquivalentTo", "IsEquivalentToAndInSimplestForm", "IsLessThan", + "IsGreaterThan", "HasFractionalPartExactlyEqualTo" -> + jsonReader.nextCustomValue(fractionAdapter) + "HasNumeratorEqualTo", "HasIntegerPartEqualTo" -> SignedInt(jsonReader.nextInt()) + "HasDenominatorEqualTo" -> NonNegativeInt(jsonReader.nextInt()) + "HasNoFractionalPart" -> error("$currentRuleType should not have an answer to parse.") + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseImageClickInputJson(jsonReader: JsonReader): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "IsInRegion" -> NormalizedString(jsonReader.nextString()) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseItemSelectionInputJson( + jsonReader: JsonReader, + setOfXlatableHtmlContentIdsAdapter: JsonAdapter + ): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "Equals", "ContainsAtLeastOneOf", "DoesNotContainAtLeastOneOf", "IsProperSubsetOf" -> + jsonReader.nextCustomValue(setOfXlatableHtmlContentIdsAdapter) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseRatioExpressionInputJson( + jsonReader: JsonReader, + ratioExpressionAdapter: JsonAdapter + ): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "Equals", "IsEquivalent" -> jsonReader.nextCustomValue(ratioExpressionAdapter) + "HasNumberOfTermsEqualTo", "HasSpecificTermEqualTo" -> NonNegativeInt(jsonReader.nextInt()) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseMultipleChoiceInputJson(jsonReader: JsonReader): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "Equals" -> NonNegativeInt(jsonReader.nextInt()) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseNumericInputJson(jsonReader: JsonReader): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "Equals", "IsLessThan", "IsGreaterThan", "IsLessThanOrEqualTo", "IsGreaterThanOrEqualTo", + "IsInclusivelyBetween", "IsWithinTolerance" -> Real(jsonReader.nextDouble()) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseTextInputJson( + jsonReader: JsonReader, + translatableStrSetAdapter: JsonAdapter + ): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "Equals", "StartsWith", "Contains", "FuzzyEquals" -> + jsonReader.nextCustomValue(translatableStrSetAdapter) + else -> error("Unsupported rule type: $currentRuleType for: $currentInteractionType.") + } + } + + private fun parseNumericExpressionInputJson(jsonReader: JsonReader) = + parseMathExpressionInputInputJson(jsonReader) + + private fun parseAlgebraicExpressionInputInputJson(jsonReader: JsonReader) = + parseMathExpressionInputInputJson(jsonReader) + + private fun parseMathEquationInputInputJson(jsonReader: JsonReader) = + parseMathExpressionInputInputJson(jsonReader) + + private fun parseMathExpressionInputInputJson(jsonReader: JsonReader): GaeInteractionObject { + val currentInteractionType = typeResolutionContext.expectedInteractionType + return when (val currentRuleType = typeResolutionContext.expectedRuleTypeName) { + "MatchesExactlyWith", "IsEquivalentTo", "MatchesUpToTrivialManipulations" -> + MathExpression(jsonReader.nextString()) + else -> error("Unsupported rule type: $currentRuleType for $currentInteractionType.") + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeMisconception.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeMisconception.kt new file mode 100644 index 00000000000..c08455a9cc9 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeMisconception.kt @@ -0,0 +1,13 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeMisconception( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String, + @Json(name = "notes") val notes: String, + @Json(name = "feedback") val feedback: String, + @Json(name = "must_be_addressed") val mustBeAddressed: Boolean +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeOutcome.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeOutcome.kt new file mode 100644 index 00000000000..38dd2b264f7 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeOutcome.kt @@ -0,0 +1,15 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeOutcome( + @Json(name = "dest") val dest: String?, + @Json(name = "dest_if_really_stuck") val destIfReallyStuck: String?, + @Json(name = "feedback") val feedback: GaeSubtitledHtml, + @Json(name = "labelled_as_correct") val labelledAsCorrect: Boolean, + @Json(name = "param_changes") val paramChanges: List, + @Json(name = "refresher_exploration_id") val refresherExplorationId: String?, + @Json(name = "missing_prerequisite_skill_id") val missingPrerequisiteSkillId: String? +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamChange.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamChange.kt new file mode 100644 index 00000000000..bc07d550c2f --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamChange.kt @@ -0,0 +1,12 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +// TODO: implement customization_args adapter. +@JsonClass(generateAdapter = true) +data class GaeParamChange( + @Json(name = "name") val name: String, + @Json(name = "generator_id") val generatorId: String, + @Json(name = "customization_args") val customizationArgs: GaeParamCustomizationArgs +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamCustomizationArgs.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamCustomizationArgs.kt new file mode 100644 index 00000000000..af741efd5b3 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamCustomizationArgs.kt @@ -0,0 +1,41 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +// TODO: Mention parsing this requires setting interaction type in the parsing context. +@JsonClass(generateAdapter = false) +sealed class GaeParamCustomizationArgs { + data class SingleString(val value: String) : GaeParamCustomizationArgs() + + data class StringList(val value: List) : GaeParamCustomizationArgs() + + class Adapter { + @FromJson + fun parseFromJson(jsonReader: JsonReader): GaeParamCustomizationArgs { + return when (val token = jsonReader.peek()) { + JsonReader.Token.STRING -> SingleString(jsonReader.nextString()) + JsonReader.Token.BEGIN_ARRAY -> StringList(jsonReader.nextArray(jsonReader::nextString)) + else -> error("Unexpected token for param customization arguments: $token.") + } + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeParamCustomizationArgs: GaeParamCustomizationArgs + ) { + when (gaeParamCustomizationArgs) { + is SingleString -> jsonWriter.value(gaeParamCustomizationArgs.value) + is StringList -> { + jsonWriter.beginArray().also { + gaeParamCustomizationArgs.value.forEach(it::value) + }.endArray() + } + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamSpec.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamSpec.kt new file mode 100644 index 00000000000..54cd495e6be --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeParamSpec.kt @@ -0,0 +1,7 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeParamSpec(@Json(name = "obj_type") val objType: String) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRecordedVoiceovers.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRecordedVoiceovers.kt new file mode 100644 index 00000000000..9a5d39dfb73 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRecordedVoiceovers.kt @@ -0,0 +1,9 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeRecordedVoiceovers( + @Json(name = "voiceovers_mapping") val voiceoversMapping: Map> +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRubric.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRubric.kt new file mode 100644 index 00000000000..33be2328585 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRubric.kt @@ -0,0 +1,10 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeRubric( + @Json(name = "difficulty") val difficulty: String, + @Json(name = "explanations") val explanations: List +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRuleSpec.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRuleSpec.kt new file mode 100644 index 00000000000..030b363358f --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeRuleSpec.kt @@ -0,0 +1,87 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +@JsonClass(generateAdapter = false) +data class GaeRuleSpec( + val ruleType: String, + val inputs: Map +) { + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + parsableRuleSpecAdapter: JsonAdapter + ): GaeRuleSpec { + typeResolutionContext.currentRuleTypeName = jsonReader.peekRuleType() + return jsonReader.nextCustomValue(parsableRuleSpecAdapter).also { + // Reset the rule type & input name. + typeResolutionContext.currentRuleInputName = null + typeResolutionContext.currentRuleTypeName = null + }.convertToGaeObject() + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeRuleSpec: GaeRuleSpec, + parsableRuleSpecAdapter: JsonAdapter + ) { + val parsable = ParsableRuleSpec(ruleType = gaeRuleSpec.ruleType, inputs = gaeRuleSpec.inputs) + parsableRuleSpecAdapter.toJson(jsonWriter, parsable) + } + + @GaeInteractionObject.RuleInput + @FromJson + fun parseRuleInputMapFromJson( + jsonReader: JsonReader, + @GaeInteractionObject.RuleInput inputAdapter: JsonAdapter + ): Map { + return jsonReader.nextObject { key -> + typeResolutionContext.currentRuleInputName = key + jsonReader.nextCustomValue(inputAdapter) + } + } + + @ToJson + fun convertRuleInputToJson( + jsonWriter: JsonWriter, + @GaeInteractionObject.RuleInput + inputs: Map, + @GaeInteractionObject.RuleInput inputAdapter: JsonAdapter + ) { + jsonWriter.beginObject() + inputs.forEach { (inputName, input) -> + jsonWriter.name(inputName) + inputAdapter.toJson(jsonWriter, input) + } + jsonWriter.endObject() + } + + @JsonClass(generateAdapter = true) + data class ParsableRuleSpec( + @Json(name = "rule_type") val ruleType: String, + @Json(name = "inputs") + @GaeInteractionObject.RuleInput + val inputs: Map + ) { + fun convertToGaeObject(): GaeRuleSpec = GaeRuleSpec(ruleType, inputs) + } + } + + private companion object { + private fun JsonReader.peekRuleType(): String { + return peekJson().use { jsonReader -> + jsonReader.nextObject { + if (it == "rule_type") jsonReader.nextString() else null + }["rule_type"] ?: error("Missing rule type in rule spec JSON object.") + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSkill.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSkill.kt new file mode 100644 index 00000000000..35cf45a84d3 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSkill.kt @@ -0,0 +1,25 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSkill( + @Json(name = "id") val id: String, + @Json(name = "description") val description: String, + @Json(name = "misconceptions") val misconceptions: List, + @Json(name = "rubrics") val rubrics: List, + @Json(name = "skill_contents") val skillContents: GaeSkillContents, + @Json(name = "language_code") val languageCode: String, + @Json(name = "misconceptions_schema_version") val misconceptionsSchemaVersion: Int, + @Json(name = "rubric_schema_version") val rubricSchemaVersion: Int, + @Json(name = "skill_contents_schema_version") val skillContentsSchemaVersion: Int, + @Json(name = "version") val version: Int, + @Json(name = "next_misconception_id") val nextMisconceptionId: Int, + @Json(name = "superseding_skill_id") val supersedingSkillId: String?, + @Json(name = "all_questions_merged") val allQuestionsMerged: Boolean, + @Json(name = "prerequisite_skill_ids") val prerequisiteSkillIds: List +) { + fun computeDirectlyReferencedSkillIds(): Set = + (listOfNotNull(supersedingSkillId) + prerequisiteSkillIds).toSet() +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSkillContents.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSkillContents.kt new file mode 100644 index 00000000000..71b186d6bd5 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSkillContents.kt @@ -0,0 +1,12 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSkillContents( + @Json(name = "explanation") val explanation: GaeSubtitledHtml, + @Json(name = "worked_examples") val workedExamples: List, + @Json(name = "recorded_voiceovers") val recordedVoiceovers: GaeRecordedVoiceovers, + @Json(name = "written_translations") val writtenTranslations: GaeWrittenTranslations +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSolution.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSolution.kt new file mode 100644 index 00000000000..9e84acf11e3 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSolution.kt @@ -0,0 +1,13 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSolution( + @Json(name = "answer_is_exclusive") val answerIsExclusive: Boolean, + @Json(name = "correct_answer") + @GaeInteractionObject.SolutionInteractionAnswer + val correctAnswer: GaeInteractionObject, + @Json(name = "explanation") val explanation: GaeSubtitledHtml +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeState.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeState.kt new file mode 100644 index 00000000000..cb9142a1633 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeState.kt @@ -0,0 +1,19 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeState( + @Json(name = "content") val content: GaeSubtitledHtml, + @Json(name = "param_changes") val paramChanges: List, + @Json(name = "interaction") val interaction: GaeInteractionInstance, + @Json(name = "classifier_model_id") val classifierModelId: String?, + @Json(name = "linked_skill_id") val linkedSkillId: String?, + @Json(name = "recorded_voiceovers") val recordedVoiceovers: GaeRecordedVoiceovers, + @Json(name = "solicit_answer_details") val solicitAnswerDetails: Boolean, + @Json(name = "card_is_checkpoint") val cardIsCheckpoint: Boolean +) { + fun computeReferencedSkillIds(): List = + listOfNotNull(linkedSkillId) + interaction.computeReferencedSkillIds() +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStory.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStory.kt new file mode 100644 index 00000000000..248963b4524 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStory.kt @@ -0,0 +1,28 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeStory( + @Json(name = "id") val id: String, + @Json(name = "title") val title: String, + @Json(name = "description") val description: String, + @Json(name = "notes") val notes: String, + @Json(name = "language_code") val languageCode: String, + @Json(name = "story_contents_schema_version") val storyContentsSchemaVersion: Int, + @Json(name = "corresponding_topic_id") val correspondingTopicId: String, + @Json(name = "version") val version: Int, + @Json(name = "story_contents") val storyContents: GaeStoryContents, + @Json(name = "thumbnail_filename") val thumbnailFilename: String?, + @Json(name = "thumbnail_bg_color") val thumbnailBgColor: String?, + @Json(name = "thumbnail_size_in_bytes") val thumbnailSizeInBytes: Int?, + @Json(name = "url_fragment") val urlFragment: String?, + @Json(name = "meta_tag_content") val metaTagContent: String +) { + fun computeReferencedExplorationIds(): Set = + storyContents.nodes.map { it.expectedExplorationId }.toSet() + + fun computeDirectlyReferencedSkillIds(): Set = + storyContents.nodes.flatMap { it.computeReferencedSkillIds() }.toSet() +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryContents.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryContents.kt new file mode 100644 index 00000000000..77c0ae88265 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryContents.kt @@ -0,0 +1,11 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeStoryContents( + @Json(name = "nodes") val nodes: List, + @Json(name = "initial_node_id") val initialNodeId: String?, + @Json(name = "next_node_id") val nextNodeId: String +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryNode.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryNode.kt new file mode 100644 index 00000000000..126e5cd0889 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryNode.kt @@ -0,0 +1,26 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeStoryNode( + @Json(name = "id") val id: String, + @Json(name = "title") val title: String, + @Json(name = "description") val description: String, + @Json(name = "thumbnail_filename") val thumbnailFilename: String?, + @Json(name = "thumbnail_bg_color") val thumbnailBgColor: String?, + @Json(name = "thumbnail_size_in_bytes") val thumbnailSizeInBytes: Int?, + @Json(name = "destination_node_ids") val destinationNodeIds: List, + @Json(name = "acquired_skill_ids") val acquiredSkillIds: List, + @Json(name = "prerequisite_skill_ids") val prerequisiteSkillIds: List, + @Json(name = "outline") val outline: String, + @Json(name = "outline_is_finalized") val outlineIsFinalized: Boolean, + @Json(name = "exploration_id") val explorationId: String? +) { + val expectedExplorationId: String by lazy { + checkNotNull(explorationId) { "Expected node to have exploration ID: $this." } + } + + fun computeReferencedSkillIds(): List = acquiredSkillIds + prerequisiteSkillIds +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryReference.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryReference.kt new file mode 100644 index 00000000000..6cf6c080f66 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeStoryReference.kt @@ -0,0 +1,10 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeStoryReference( + @Json(name = "story_id") val storyId: String, + @Json(name = "story_is_published") val storyIsPublished: Boolean +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtitledHtml.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtitledHtml.kt new file mode 100644 index 00000000000..14366dfa18a --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtitledHtml.kt @@ -0,0 +1,10 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSubtitledHtml( + @Json(name = "content_id") override val contentId: String, + @Json(name = "html") override val text: String +) : SubtitledText diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtitledUnicode.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtitledUnicode.kt new file mode 100644 index 00000000000..6ff5a83cec2 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtitledUnicode.kt @@ -0,0 +1,10 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSubtitledUnicode( + @Json(name = "content_id") override val contentId: String, + @Json(name = "unicode_str") override val text: String +) : SubtitledText diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopic.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopic.kt new file mode 100644 index 00000000000..a64cdee2e96 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopic.kt @@ -0,0 +1,15 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSubtopic( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String, + @Json(name = "skill_ids") val skillIds: List, + @Json(name = "thumbnail_filename") val thumbnailFilename: String?, + @Json(name = "thumbnail_bg_color") val thumbnailBgColor: String?, + @Json(name = "thumbnail_size_in_bytes") val thumbnailSizeInBytes: Int?, + @Json(name = "url_fragment") val urlFragment: String +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopicPage.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopicPage.kt new file mode 100644 index 00000000000..38b9b672231 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopicPage.kt @@ -0,0 +1,14 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSubtopicPage( + @Json(name = "id") val id: String, + @Json(name = "topic_id") val topicId: String, + @Json(name = "page_contents") val pageContents: GaeSubtopicPageContents, + @Json(name = "page_contents_schema_version") val pageContentsSchemaVersion: Int, + @Json(name = "language_code") val languageCode: String, + @Json(name = "version") val version: Int +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopicPageContents.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopicPageContents.kt new file mode 100644 index 00000000000..bf6bb28900e --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeSubtopicPageContents.kt @@ -0,0 +1,11 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeSubtopicPageContents( + @Json(name = "subtitled_html") val subtitledHtml: GaeSubtitledHtml, + @Json(name = "recorded_voiceovers") val recordedVoiceovers: GaeRecordedVoiceovers, + @Json(name = "written_translations") val writtenTranslations: GaeWrittenTranslations +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTopic.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTopic.kt new file mode 100644 index 00000000000..e634ce347d9 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTopic.kt @@ -0,0 +1,36 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeTopic( + @Json(name = "id") val id: String, + @Json(name = "name") val name: String, + @Json(name = "abbreviated_name") val abbreviatedName: String?, + @Json(name = "url_fragment") val urlFragment: String?, + @Json(name = "thumbnail_filename") val thumbnailFilename: String?, + @Json(name = "thumbnail_bg_color") val thumbnailBgColor: String?, + @Json(name = "thumbnail_size_in_bytes") val thumbnailSizeInBytes: Int?, + @Json(name = "description") val description: String, + @Json(name = "canonical_story_references") val canonicalStoryRefs: List, + @Json(name = "additional_story_references") val additionalStoryRefs: List, + @Json(name = "uncategorized_skill_ids") val uncategorizedSkillIds: List, + @Json(name = "subtopics") val subtopics: List, + @Json(name = "subtopic_schema_version") val subtopicSchemaVersion: Int, + @Json(name = "next_subtopic_id") val nextSubtopicId: Int, + @Json(name = "language_code") val languageCode: String, + @Json(name = "version") val version: Int, + @Json(name = "story_reference_schema_version") val storyReferenceSchemaVersion: Int, + @Json(name = "meta_tag_content") val metaTagContent: String, + @Json(name = "practice_tab_is_displayed") val practiceTabIsDisplayed: Boolean, + @Json(name = "page_title_fragment_for_web") val pageTitleFragmentForWeb: String?, + @Json(name = "skill_ids_for_diagnostic_test") val skillIdsForDiagnosticTest: List +) { + fun computeContainedSubtopicMap(): Map = subtopics.associateBy { it.id } + + fun computeReferencedStoryIds(): Set = canonicalStoryRefs.map { it.storyId }.toSet() + + fun computeDirectlyReferencedSkillIds(): Set = + (subtopics.flatMap { it.skillIds } + uncategorizedSkillIds).toSet() +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTranslatableContentFormat.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTranslatableContentFormat.kt new file mode 100644 index 00000000000..a7a633f1dae --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTranslatableContentFormat.kt @@ -0,0 +1,42 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson + +@JsonClass(generateAdapter = false) +enum class GaeTranslatableContentFormat { + HTML, + UNICODE_STRING, + SET_OF_NORMALIZED_STRING, + SET_OF_UNICODE_STRING; + + class Adapter { + @FromJson + fun parseFromJson(jsonReader: JsonReader): GaeTranslatableContentFormat { + return when (val rawFormatStr = jsonReader.nextString()) { + "html" -> HTML + "unicode" -> UNICODE_STRING + "set_of_normalized_string" -> SET_OF_NORMALIZED_STRING + "set_of_unicode_string" -> SET_OF_UNICODE_STRING + else -> error("Unsupported translatable content format: $rawFormatStr.") + } + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeTranslatableContentFormat: GaeTranslatableContentFormat + ) { + val textRepresentation = when (gaeTranslatableContentFormat) { + HTML -> "html" + UNICODE_STRING -> "unicode" + SET_OF_NORMALIZED_STRING -> "set_of_normalized_string" + SET_OF_UNICODE_STRING -> "set_of_unicode_string" + } + jsonWriter.value(textRepresentation) + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTranslatedContent.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTranslatedContent.kt new file mode 100644 index 00000000000..c0883e204f4 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeTranslatedContent.kt @@ -0,0 +1,99 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.HTML +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.SET_OF_NORMALIZED_STRING +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.SET_OF_UNICODE_STRING +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.UNICODE_STRING + +@JsonClass(generateAdapter = false) +data class GaeTranslatedContent( + val contentValue: Translation, + val contentFormat: GaeTranslatableContentFormat, + val needsUpdate: Boolean +) { + @JsonClass(generateAdapter = false) + sealed class Translation { + data class SingleString(val value: String) : Translation() + + data class StringList(val value: List) : Translation() + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson(jsonReader: JsonReader): Translation { + return when (typeResolutionContext.expectedContentFormat) { + HTML, UNICODE_STRING -> SingleString(jsonReader.nextString()) + SET_OF_NORMALIZED_STRING, SET_OF_UNICODE_STRING -> + StringList(jsonReader.nextArray(jsonReader::nextString)) + } + } + + @ToJson + fun convertToJson(jsonWriter: JsonWriter, translation: Translation) { + when (translation) { + is SingleString -> jsonWriter.value(translation.value) + is StringList -> + jsonWriter.beginArray().also { translation.value.forEach(jsonWriter::value) }.endArray() + } + } + } + } + + @JsonClass(generateAdapter = true) + data class ParsableGaeTranslatedContent( + @Json(name = "content_value") val contentValue: Translation, + @Json(name = "content_format") val contentFormat: GaeTranslatableContentFormat, + @Json(name = "needs_update") val needsUpdate: Boolean + ) { + fun convertToGaeObject(): GaeTranslatedContent = + GaeTranslatedContent(contentValue, contentFormat, needsUpdate) + } + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + gaeTranslatableContentFormatAdapter: JsonAdapter, + parsableGaeTranslatedContentAdapter: JsonAdapter + ): GaeTranslatedContent { + typeResolutionContext.currentContentFormat = + jsonReader.peekTranslatableContentFormat(gaeTranslatableContentFormatAdapter) + return jsonReader.nextCustomValue( + parsableGaeTranslatedContentAdapter + ).convertToGaeObject().also { typeResolutionContext.currentContentFormat = null } + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeTranslatedContent: GaeTranslatedContent, + parsableGaeTranslatedContentAdapter: JsonAdapter + ) { + val parsable = ParsableGaeTranslatedContent( + contentValue = gaeTranslatedContent.contentValue, + contentFormat = gaeTranslatedContent.contentFormat, + needsUpdate = gaeTranslatedContent.needsUpdate + ) + parsableGaeTranslatedContentAdapter.toJson(jsonWriter, parsable) + } + } + + private companion object { + private fun JsonReader.peekTranslatableContentFormat( + contentFormatAdapter: JsonAdapter + ): GaeTranslatableContentFormat { + return peekJson().use { jsonReader -> + jsonReader.nextObject { + if (it == "content_format") contentFormatAdapter.fromJson(jsonReader) else null + }["content_format"] + ?: error("Missing translatable content format in translation JSON object.") + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeVoiceover.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeVoiceover.kt new file mode 100644 index 00000000000..8546f2632a3 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeVoiceover.kt @@ -0,0 +1,12 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeVoiceover( + @Json(name = "filename") val filename: String, + @Json(name = "file_size_bytes") val fileSizeBytes: Int, + @Json(name = "needs_update") val needsUpdate: Boolean, + @Json(name = "duration_secs") val durationSecs: Float +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWorkedExample.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWorkedExample.kt new file mode 100644 index 00000000000..be022547314 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWorkedExample.kt @@ -0,0 +1,10 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeWorkedExample( + @Json(name = "question") val question: GaeSubtitledHtml, + @Json(name = "explanation") val explanation: GaeSubtitledHtml +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWrittenTranslation.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWrittenTranslation.kt new file mode 100644 index 00000000000..00fd405bc17 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWrittenTranslation.kt @@ -0,0 +1,98 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.HTML +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.SET_OF_NORMALIZED_STRING +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.SET_OF_UNICODE_STRING +import org.oppia.android.scripts.gae.json.GaeTranslatableContentFormat.UNICODE_STRING + +@JsonClass(generateAdapter = false) +data class GaeWrittenTranslation( + val dataFormat: GaeTranslatableContentFormat, + val translation: Translation, + val needsUpdate: Boolean +) { + @JsonClass(generateAdapter = false) + sealed class Translation { + data class SingleString(val value: String) : Translation() + + data class StringList(val value: List) : Translation() + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson(jsonReader: JsonReader): Translation { + return when (typeResolutionContext.expectedContentFormat) { + HTML, UNICODE_STRING -> SingleString(jsonReader.nextString()) + SET_OF_NORMALIZED_STRING, SET_OF_UNICODE_STRING -> + StringList(jsonReader.nextArray(jsonReader::nextString)) + } + } + + @ToJson + fun convertToJson(jsonWriter: JsonWriter, translation: Translation) { + when (translation) { + is SingleString -> jsonWriter.value(translation.value) + is StringList -> + jsonWriter.beginArray().also { translation.value.forEach(jsonWriter::value) }.endArray() + } + } + } + } + + @JsonClass(generateAdapter = true) + data class ParsableWrittenTranslation( + @Json(name = "data_format") val dataFormat: GaeTranslatableContentFormat, + @Json(name = "translation") val translation: Translation, + @Json(name = "needs_update") val needsUpdate: Boolean + ) { + fun convertToGaeObject(): GaeWrittenTranslation = + GaeWrittenTranslation(dataFormat, translation, needsUpdate) + } + + class Adapter(private val typeResolutionContext: TypeResolutionContext) { + @FromJson + fun parseFromJson( + jsonReader: JsonReader, + gaeTranslatableContentFormatAdapter: JsonAdapter, + parsableWrittenTranslationAdapter: JsonAdapter + ): GaeWrittenTranslation { + typeResolutionContext.currentContentFormat = + jsonReader.peekTranslatableContentFormat(gaeTranslatableContentFormatAdapter) + return jsonReader.nextCustomValue( + parsableWrittenTranslationAdapter + ).convertToGaeObject().also { typeResolutionContext.currentContentFormat = null } + } + + @ToJson + fun convertToJson( + jsonWriter: JsonWriter, + gaeWrittenTranslation: GaeWrittenTranslation, + parsableWrittenTranslationAdapter: JsonAdapter + ) { + val parsable = ParsableWrittenTranslation( + dataFormat = gaeWrittenTranslation.dataFormat, + translation = gaeWrittenTranslation.translation, + needsUpdate = gaeWrittenTranslation.needsUpdate + ) + parsableWrittenTranslationAdapter.toJson(jsonWriter, parsable) + } + } + + private companion object { + private fun JsonReader.peekTranslatableContentFormat( + contentFormatAdapter: JsonAdapter + ): GaeTranslatableContentFormat { + return peekJson().use { jsonReader -> + jsonReader.nextObject { + if (it == "data_format") contentFormatAdapter.fromJson(jsonReader) else null + }["data_format"] ?: error("Missing translatable content format in translation JSON object.") + } + } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWrittenTranslations.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWrittenTranslations.kt new file mode 100644 index 00000000000..950ce64e110 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/GaeWrittenTranslations.kt @@ -0,0 +1,10 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GaeWrittenTranslations( + @Json(name = "translations_mapping") + val translationsMapping: Map> +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/JsonReaderExtensions.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/JsonReaderExtensions.kt new file mode 100644 index 00000000000..4a356b7ea3d --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/JsonReaderExtensions.kt @@ -0,0 +1,56 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader + +fun JsonReader.nextArray(readElement: () -> T): List { + beginArray() + return generateSequence { maybeReadElement(readElement) }.toList().also { endArray() } +} + +// TODO: Document that a null return value skips the element for that key. +fun JsonReader.nextObject(readElement: (String) -> V?): Map { + beginObject() + return generateSequence { + var nextElement = maybeReadObjectElement(readElement) + while (nextElement is JsonObjectElement.Unknown) { + // Skip the element and move to the next one. + skipValue() + nextElement = maybeReadObjectElement(readElement) + } + @Suppress("KotlinConstantConditions") // Branch must be present in this case. + when (nextElement) { + is JsonObjectElement.Pair -> nextElement.name to nextElement.value + null -> null // No more elements exist. + is JsonObjectElement.Unknown -> error("Impossible case occurred when reading object.") + } + }.toMap().also { endObject() } +} + +inline fun JsonReader.nextCustomValue(adapter: JsonAdapter): T { + return checkNotNull(adapter.fromJson(this)) { + "Reader does not have a next value corresponding to custom type ${T::class.simpleName} for" + + " adapter: $adapter." + } +} + +private fun JsonReader.maybeReadElement(readElement: () -> T) = + if (hasNext()) readElement() else null + +private fun JsonReader.maybeReadObjectElement( + readElement: (String) -> V? +): JsonObjectElement? { + return maybeReadElement { + val name = nextName() + val value = readElement(name) + if (value != null) { + JsonObjectElement.Pair(name, value) + } else JsonObjectElement.Unknown() + } +} + +private sealed class JsonObjectElement { + class Unknown : JsonObjectElement() + + data class Pair(val name: String, val value: T) : JsonObjectElement() +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/MoshiFactory.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/MoshiFactory.kt new file mode 100644 index 00000000000..23528705543 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/MoshiFactory.kt @@ -0,0 +1,33 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import org.oppia.android.scripts.gae.json.GaeCustomizationArgValue.GaeImageWithRegions.GaeLabeledRegion.GaeNormalizedRectangle2d + +object MoshiFactory { + fun createMoshi(): Moshi { + return Moshi.Builder().apply { + val typeResolutionContext = TypeResolutionContext() + add(AndroidActivityRequests.Adapter()) + add(AndroidActivityRequests.ActivityRequest.Adapter()) + add(GaeCustomizationArgValue.Adapter(typeResolutionContext)) + add(GaeNormalizedRectangle2d.Adapter()) + add(GaeInteractionInstance.Adapter(typeResolutionContext)) + add(GaeInteractionObject.Adapter(typeResolutionContext)) + add(GaeInteractionObject.TranslatableHtmlContentId.Adapter()) + add(GaeInteractionObject.SetOfXlatableContentIds.Adapter()) + add(GaeInteractionObject.SetsOfXlatableContentIds.Adapter()) + add(GaeInteractionObject.RatioExpression.Adapter()) + add(GaeParamCustomizationArgs.Adapter()) + add(GaeRuleSpec.Adapter(typeResolutionContext)) + add(GaeWrittenTranslation.Adapter(typeResolutionContext)) + add(GaeWrittenTranslation.Translation.Adapter(typeResolutionContext)) + add(GaeTranslatedContent.Adapter(typeResolutionContext)) + add(GaeTranslatedContent.Translation.Adapter(typeResolutionContext)) + add(GaeTranslatableContentFormat.Adapter()) + add(GaeInteractionCustomizationArgsMap.Adapter(typeResolutionContext)) + add(GaeEntityTranslations.Adapter) + add(KotlinJsonAdapterFactory()) // TODO: Remove this so that it can be done w/o reflection. + }.build() + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/SubtitledText.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/SubtitledText.kt new file mode 100644 index 00000000000..93dfbac392d --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/SubtitledText.kt @@ -0,0 +1,6 @@ +package org.oppia.android.scripts.gae.json + +interface SubtitledText { + val contentId: String + val text: String +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/TypeResolutionContext.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/TypeResolutionContext.kt new file mode 100644 index 00000000000..0b8071884e0 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/TypeResolutionContext.kt @@ -0,0 +1,72 @@ +package org.oppia.android.scripts.gae.json + +import org.oppia.proto.v1.structure.InteractionInstanceDto + +class TypeResolutionContext { + private val currentInteractionTypeStore = + createThreadLocal() + private val currentCustomizationArgKeyNameStore = createThreadLocal() + private val currentRuleTypeNameStore = createThreadLocal() + private val currentRuleInputNameStore = createThreadLocal() + private val currentContentFormatStore = createThreadLocal() + + var currentInteractionType: InteractionInstanceDto.InteractionTypeCase? + get() = currentInteractionTypeStore.get() + set(value) = currentInteractionTypeStore.set(value) + + var currentCustomizationArgKeyName: String? + get() = currentCustomizationArgKeyNameStore.get() + set(value) = currentCustomizationArgKeyNameStore.set(value) + + var currentRuleTypeName: String? + get() = currentRuleTypeNameStore.get() + set(value) = currentRuleTypeNameStore.set(value) + + var currentRuleInputName: String? + get() = currentRuleInputNameStore.get() + set(value) = currentRuleInputNameStore.set(value) + + var currentContentFormat: GaeTranslatableContentFormat? + get() = currentContentFormatStore.get() + set(value) = currentContentFormatStore.set(value) + + val expectedInteractionType: InteractionInstanceDto.InteractionTypeCase + get() { + return checkNotNull(currentInteractionType) { + "Expected to parse this object within an interaction." + } + } + + val expectedCustomizationArgKeyName: String + get() { + return checkNotNull(currentCustomizationArgKeyName) { + "Expected to parse this object within a customization argument." + } + } + + val expectedRuleTypeName: String + get() { + return checkNotNull(currentRuleTypeName) { + "Expected to parse this object within a rule spec." + } + } + + val expectedRuleInputName: String + get() { + return checkNotNull(currentRuleInputName) { + "Expected to parse this object within a rule spec." + } + } + + val expectedContentFormat: GaeTranslatableContentFormat + get() { + return checkNotNull(currentContentFormat) { + "Expected to parse this object within a translation context." + } + } + + private companion object { + private fun createThreadLocal(defaultValue: T? = null): ThreadLocal = + ThreadLocal.withInitial { defaultValue } + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/json/VersionedStructure.kt b/scripts/src/java/org/oppia/android/scripts/gae/json/VersionedStructure.kt new file mode 100644 index 00000000000..2ab963a9004 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/json/VersionedStructure.kt @@ -0,0 +1,15 @@ +package org.oppia.android.scripts.gae.json + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class VersionedStructure( + @Json(name = "id") val id: String, + @Json(name = "payload") val payload: T, + @Json(name = "language_code") val languageCode: String?, + @Json(name = "version") val version: Int? +) { + val expectedVersion: Int + get() = checkNotNull(version) { "Expected activity $id to be versioned." } +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/proto/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/gae/proto/BUILD.bazel new file mode 100644 index 00000000000..589c2d7ab9c --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/proto/BUILD.bazel @@ -0,0 +1,16 @@ +load("@rules_java//java:defs.bzl", "java_proto_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +# TODO: The dependencies in this module are a bit broken with compat/. Needs refactor. + +proto_library( + name = "extra_exploration_definitions_proto", + srcs = ["extra_exploration_definitions.proto"], + deps = ["//third_party:oppia_proto_api_protos"], +) + +java_proto_library( + name = "extra_exploration_definitions_java_proto", + visibility = ["//scripts/src/java/org/oppia/android/scripts/gae:__subpackages__"], + deps = [":extra_exploration_definitions_proto"], +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/proto/extra_exploration_definitions.proto b/scripts/src/java/org/oppia/android/scripts/gae/proto/extra_exploration_definitions.proto new file mode 100644 index 00000000000..1c1b8227ba1 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/proto/extra_exploration_definitions.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package org.oppia.android.scripts.gae.proto; + +import "org/oppia/proto/v1/structure/languages.proto"; +import "org/oppia/proto/v1/structure/objects.proto"; + +option java_package = "org.oppia.android.scripts.gae.proto"; +option java_multiple_files = true; + +message CustomizationArgValue { + oneof value_type { + int32 integer = 1; + bool boolean = 2; + org.oppia.proto.v1.structure.SubtitledTextDto subtitled_text_dto = 3; + StringList string_list = 4; + SubtitledTextList subtitled_text_list = 5; + org.oppia.proto.v1.structure.ImageWithRegionsDto image_with_regions_dto = 6; + } +} + +message StringList { + repeated string string = 1; +} + +message SubtitledTextList { + repeated org.oppia.proto.v1.structure.SubtitledTextDto subtitled_html = 1; +}