Skip to content

Commit

Permalink
Fix #386: Add support for local caching of audio & image assets (#399)
Browse files Browse the repository at this point in the history
* Ensured progress, thumbnails, and stats are consistent.

This change ensures that completed progress is consistent and correct
everywhere for fractions and ratios topics.

It also ensures that there are thumbnails defined for chapters, and that
all thumbnails are consistent regardless of which screen topics,
stories, or chapters are viewed from.

It updates the reported lesson count to be correct.

* Use a different color for the fractions topic vs. ratios.

* Remove dev comment.

* Compute actual JSON disk size requirement for each topic.

* Introduce AssetRepository to prefetch JSON files, and support offline
caching for audio files.

* Introduce custom local image caching.

* Add module to disable local asset caching by default, and ensure tests
pass.

* Post-merge fix.
  • Loading branch information
BenHenning authored Nov 20, 2019
1 parent adb33f9 commit b8100ad
Show file tree
Hide file tree
Showing 24 changed files with 610 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.oppia.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputMo
import org.oppia.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule
import org.oppia.domain.classify.rules.numericinput.NumericInputRuleModule
import org.oppia.domain.classify.rules.textinput.TextInputRuleModule
import org.oppia.util.caching.CachingModule
import org.oppia.util.gcsresource.GcsResourceModule
import org.oppia.util.logging.LoggerModule
import org.oppia.util.parser.GlideImageLoaderModule
Expand All @@ -29,7 +30,7 @@ import javax.inject.Singleton
ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class,
NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class,
InteractionsModule::class, GcsResourceModule::class, GlideImageLoaderModule::class, ImageParsingModule::class,
HtmlParserEntityTypeModule::class
HtmlParserEntityTypeModule::class, CachingModule::class
])
interface ApplicationComponent {
@Component.Builder
Expand Down
1 change: 1 addition & 0 deletions domain/src/main/assets/about_oppia.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"exploration_id": "1",
"author_notes": "",
"blurb": "",
"category": "Welcome",
Expand Down
1 change: 1 addition & 0 deletions domain/src/main/assets/oppia_exploration.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"exploration_id": "3",
"author_notes": "",
"blurb": "",
"category": "Welcome",
Expand Down
1 change: 1 addition & 0 deletions domain/src/main/assets/prototype_exploration.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"exploration_id": "2",
"language_code": "en",
"param_specs": {},
"param_changes": [],
Expand Down
1 change: 1 addition & 0 deletions domain/src/main/assets/welcome.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"exploration_id": "0",
"author_notes": "",
"blurb": "",
"category": "Welcome",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.oppia.domain.audio

import android.media.MediaDataSource
import android.media.MediaPlayer
import android.os.Build
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
Expand All @@ -9,6 +11,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.oppia.util.caching.AssetRepository
import org.oppia.util.caching.CacheAssetsLocally
import org.oppia.util.data.AsyncResult
import org.oppia.util.logging.Logger
import org.oppia.util.threading.BackgroundDispatcher
Expand All @@ -28,7 +32,9 @@ import kotlin.concurrent.withLock
@Singleton
class AudioPlayerController @Inject constructor(
private val logger: Logger,
@BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher
private val assetRepository: AssetRepository,
@BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher,
@CacheAssetsLocally private val cacheAssetsLocally: Boolean
) {

inner class AudioMutableLiveData : MutableLiveData<AsyncResult<PlayProgress>>() {
Expand Down Expand Up @@ -125,7 +131,37 @@ class AudioPlayerController @Inject constructor(

private fun prepareDataSource(url: String) {
try {
mediaPlayer.setDataSource(url)
if (cacheAssetsLocally && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val mediaDataSource: MediaDataSource = object : MediaDataSource() {
private val audioFileBuffer: ByteArray by lazy {
// Ensure that the download occurs off the main thread to avoid strict mode violations for
// cases when we need to stream audio.
assetRepository.loadRemoteBinaryAsset(url)()
}

// https://medium.com/@jacks205/implementing-your-own-android-mediadatasource-e67adb070731.
override fun readAt(position: Long, buffer: ByteArray?, offset: Int, size: Int): Int {
checkNotNull(buffer)
val intPosition = position.toInt()
if (intPosition >= audioFileBuffer.size) {
return -1
}
val availableData = audioFileBuffer.size - intPosition
val adjustedSize = size.coerceIn(0 until availableData)
audioFileBuffer.copyInto(buffer, offset, intPosition, intPosition + adjustedSize)
return adjustedSize
}

override fun getSize(): Long {
return audioFileBuffer.size.toLong()
}

override fun close() {}
}
mediaPlayer.setDataSource(mediaDataSource)
} else {
mediaPlayer.setDataSource(url)
}
mediaPlayer.prepareAsync()
} catch (e: IOException) {
logger.e("AudioPlayerController", "Failed to set data source for media player", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import org.oppia.domain.util.StateRetriever
import java.io.IOException
import javax.inject.Inject

const val TEST_EXPLORATION_ID_5 = "DIWZiVgs0km-"
const val TEST_EXPLORATION_ID_6 = "test_exp_id_6"
const val TEST_EXPLORATION_ID_30 = "30"
const val TEST_EXPLORATION_ID_7 = "test_exp_id_7"
const val TEST_EXPLORATION_ID_5 = "0"
const val TEST_EXPLORATION_ID_6 = "1"
const val TEST_EXPLORATION_ID_30 = "2"
const val TEST_EXPLORATION_ID_7 = "3"

// TODO(#59): Make this class inaccessible outside of the domain package except for tests. UI code should not be allowed
// to depend on this utility.
Expand All @@ -27,9 +27,9 @@ class ExplorationRetriever @Inject constructor(
private val jsonAssetRetriever: JsonAssetRetriever,
private val stateRetriever: StateRetriever
) {
// TODO(#169): Force callers of this method on a background thread.
/** Loads and returns an exploration for the specified exploration ID, or fails. */
@Suppress("RedundantSuspendModifier") // Force callers to call this on a background thread.
internal suspend fun loadExploration(explorationId: String): Exploration {
internal fun loadExploration(explorationId: String): Exploration {
return when (explorationId) {
TEST_EXPLORATION_ID_5 -> loadExplorationFromAsset("welcome.json")
TEST_EXPLORATION_ID_6 -> loadExplorationFromAsset("about_oppia.json")
Expand All @@ -50,6 +50,7 @@ class ExplorationRetriever @Inject constructor(
try {
val explorationObject = jsonAssetRetriever.loadJsonFromAsset(assetName) ?: return Exploration.getDefaultInstance()
return Exploration.newBuilder()
.setId(explorationObject.getString("exploration_id"))
.setTitle(explorationObject.getString("title"))
.setLanguageCode(explorationObject.getString("language_code"))
.setInitStateName(explorationObject.getString("init_state_name"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const val FRACTIONS_QUESTION_ID_8 = "AciwQAtcvZfI"
const val FRACTIONS_QUESTION_ID_9 = "YQwbX2r6p3Xj"
const val FRACTIONS_QUESTION_ID_10 = "NNuVGmbJpnj5"
const val RATIOS_QUESTION_ID_0 = "QiKxvAXpvUbb"
private val TOPIC_FILE_ASSOCIATIONS = mapOf(
val TOPIC_FILE_ASSOCIATIONS = mapOf(
FRACTIONS_TOPIC_ID to listOf(
"fractions_exploration0.json",
"fractions_exploration1.json",
Expand Down
176 changes: 175 additions & 1 deletion domain/src/main/java/org/oppia/domain/topic/TopicListController.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
package org.oppia.domain.topic

import android.os.SystemClock
import android.text.Html
import android.text.Spannable
import android.text.style.ImageSpan
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.json.JSONObject
import org.oppia.app.model.AnswerGroup
import org.oppia.app.model.ChapterPlayState
import org.oppia.app.model.Exploration
import org.oppia.app.model.Hint
import org.oppia.app.model.Interaction
import org.oppia.app.model.LessonThumbnail
import org.oppia.app.model.LessonThumbnailGraphic
import org.oppia.app.model.OngoingStoryList
import org.oppia.app.model.Outcome
import org.oppia.app.model.PromotedStory
import org.oppia.app.model.Solution
import org.oppia.app.model.State
import org.oppia.app.model.StorySummary
import org.oppia.app.model.SubtitledHtml
import org.oppia.app.model.Topic
import org.oppia.app.model.TopicList
import org.oppia.app.model.TopicSummary
import org.oppia.app.model.Voiceover
import org.oppia.app.model.VoiceoverMapping
import org.oppia.domain.exploration.ExplorationRetriever
import org.oppia.util.caching.AssetRepository
import org.oppia.domain.util.JsonAssetRetriever
import org.oppia.util.caching.CacheAssetsLocally
import org.oppia.util.data.AsyncResult
import org.oppia.util.logging.Logger
import org.oppia.util.parser.DefaultGcsPrefix
import org.oppia.util.parser.DefaultGcsResource
import org.oppia.util.parser.ImageDownloadUrlTemplate
import org.oppia.util.threading.BackgroundDispatcher
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -45,15 +72,73 @@ val TOPIC_SKILL_ASSOCIATIONS = mapOf(
RATIOS_TOPIC_ID to listOf(RATIOS_SKILL_ID_0)
)

private const val CUSTOM_IMG_TAG = "oppia-noninteractive-image"
private const val REPLACE_IMG_TAG = "img"
private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value"
private const val REPLACE_IMG_FILE_PATH_ATTRIBUTE = "src"

private val EVICTION_TIME_MILLIS = TimeUnit.DAYS.toMillis(1)

/** Controller for retrieving the list of topics available to the learner to play. */
@Singleton
class TopicListController @Inject constructor(
private val jsonAssetRetriever: JsonAssetRetriever,
private val topicController: TopicController,
private val storyProgressController: StoryProgressController
private val storyProgressController: StoryProgressController,
private val explorationRetriever: ExplorationRetriever,
@BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher,
@CacheAssetsLocally private val cacheAssetsLocally: Boolean,
@DefaultGcsPrefix private val gcsPrefix: String,
@DefaultGcsResource private val gcsResource: String,
@ImageDownloadUrlTemplate private val imageDownloadUrlTemplate: String,
logger: Logger,
assetRepository: AssetRepository
) {
private val backgroundScope = CoroutineScope(backgroundDispatcher)

init {
// TODO(#169): Download data reactively rather than during start-up to avoid blocking the main thread on the whole
// load operation.
if (cacheAssetsLocally) {
// Ensure all JSON files are available in memory for quick retrieval.
val allFiles = TOPIC_FILE_ASSOCIATIONS.values.flatten()
val primeAssetJobs = allFiles.map {
backgroundScope.async {
assetRepository.primeTextFileFromLocalAssets(it)
}
}

// The following job encapsulates all startup loading. NB: We don't currently wait on this job to complete because
// it's fine to try to load the assets at the same time as priming the cache, and it's unlikely the user can get
// into an exploration fast enough to try to load an asset that would trigger a strict mode crash.
backgroundScope.launch {
primeAssetJobs.forEach { it.await() }

// Only download binary assets for one fractions lesson. The others can still be streamed.
val explorations = loadExplorations(listOf(FRACTIONS_EXPLORATION_ID_1))
val voiceoverUrls = collectAllDesiredVoiceoverUrls(explorations).toSet()
val imageUrls = collectAllImageUrls(explorations).toSet()
logger.d(
"AssetRepo", "Downloading up to ${voiceoverUrls.size} voiceovers and ${imageUrls.size} images"
)
val startTime = SystemClock.elapsedRealtime()
val voiceoverDownloadJobs = voiceoverUrls.map { url ->
backgroundScope.async {
assetRepository.primeRemoteBinaryAsset(url)
}
}
val imageDownloadJobs = imageUrls.map { url ->
backgroundScope.async {
assetRepository.primeRemoteBinaryAsset(url)
}
}
(voiceoverDownloadJobs + imageDownloadJobs).forEach { it.await() }
val endTime = SystemClock.elapsedRealtime()
logger.d("AssetRepo", "Finished downloading voiceovers and images in ${endTime - startTime}ms")
}
}
}

/**
* Returns the list of [TopicSummary]s currently tracked by the app, possibly up to
* [EVICTION_TIME_MILLIS] old.
Expand Down Expand Up @@ -186,6 +271,95 @@ class TopicListController @Inject constructor(
}
return promotedStoryBuilder.build()
}

private fun loadExplorations(explorationIds: Collection<String>): Collection<Exploration> {
return explorationIds.map(explorationRetriever::loadExploration)
}

private fun collectAllDesiredVoiceoverUrls(explorations: Collection<Exploration>): Collection<String> {
return explorations.flatMap(::collectDesiredVoiceoverUrls)
}

private fun collectDesiredVoiceoverUrls(exploration: Exploration): Collection<String> {
return extractDesiredVoiceovers(exploration).map { voiceover -> getUriForVoiceover(exploration.id, voiceover) }
}

private fun extractDesiredVoiceovers(exploration: Exploration): Collection<Voiceover> {
val states = exploration.statesMap.values
val mappings = states.flatMap(::getDesiredVoiceoverMapping)
return mappings.flatMap { it.voiceoverMappingMap.values }
}

private fun getDesiredVoiceoverMapping(state: State): Collection<VoiceoverMapping> {
val voiceoverMappings = state.recordedVoiceoversMap
val contentIds = extractDesiredContentIds(state).filter(String::isNotEmpty)
return voiceoverMappings.filterKeys(contentIds::contains).values
}

/** Returns all collection IDs from the specified [State] that can actually be played by a user. */
private fun extractDesiredContentIds(state: State): Collection<String> {
val stateContentSubtitledHtml = state.content
val defaultFeedbackSubtitledHtml = state.interaction.defaultOutcome.feedback
val answerGroupOutcomes = state.interaction.answerGroupsList.map(AnswerGroup::getOutcome)
val answerGroupsSubtitledHtml = answerGroupOutcomes.map(Outcome::getFeedback)
val targetedSubtitledHtmls = answerGroupsSubtitledHtml + stateContentSubtitledHtml + defaultFeedbackSubtitledHtml
return targetedSubtitledHtmls.map(SubtitledHtml::getContentId)
}

private fun collectAllImageUrls(explorations: Collection<Exploration>): Collection<String> {
return explorations.flatMap(::collectImageUrls)
}

private fun collectImageUrls(exploration: Exploration): Collection<String> {
val subtitledHtmls = collectSubtitledHtmls(exploration)
val imageSources = subtitledHtmls.flatMap(::getImageSourcesFromHtml)
return imageSources.toSet().map { imageSource ->
getUriForImage(exploration.id, imageSource)
}
}

private fun collectSubtitledHtmls(exploration: Exploration): Collection<SubtitledHtml> {
val states = exploration.statesMap.values
val stateContents = states.map(State::getContent)
val stateInteractions = states.map(State::getInteraction)
val stateSolutions = stateInteractions.map(Interaction::getSolution).map(Solution::getExplanation)
val stateHints = stateInteractions.map(Interaction::getHint).map(Hint::getHintContent)
val answerGroupOutcomes = stateInteractions.flatMap(Interaction::getAnswerGroupsList).map(AnswerGroup::getOutcome)
val defaultOutcomes = stateInteractions.map(Interaction::getDefaultOutcome)
val outcomeFeedbacks = (answerGroupOutcomes + defaultOutcomes).map(Outcome::getFeedback)
val allSubtitledHtmls = stateContents + stateSolutions + stateHints + outcomeFeedbacks
return allSubtitledHtmls.filter { it != SubtitledHtml.getDefaultInstance() }
}

private fun getImageSourcesFromHtml(subtitledHtml: SubtitledHtml): Collection<String> {
val parsedHtml = parseHtml(replaceCustomOppiaImageTag(subtitledHtml.html))
val imageSpans = parsedHtml.getSpans(0, parsedHtml.length, ImageSpan::class.java)
return imageSpans.toList().map(ImageSpan::getSource)
}

private fun parseHtml(html: String): Spannable {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY) as Spannable
} else {
Html.fromHtml(html) as Spannable
}
}

private fun replaceCustomOppiaImageTag(html: String): String {
return html.replace(CUSTOM_IMG_TAG, REPLACE_IMG_TAG)
.replace(CUSTOM_IMG_FILE_PATH_ATTRIBUTE, REPLACE_IMG_FILE_PATH_ATTRIBUTE)
.replace("&amp;quot;", "")
}

private fun getUriForVoiceover(explorationId: String, voiceover: Voiceover): String {
return "https://storage.googleapis.com/${gcsResource}exploration/$explorationId/assets/audio/${voiceover.fileName}"
}

private fun getUriForImage(explorationId: String, imageFileName: String): String {
return gcsPrefix + gcsResource + String.format(
imageDownloadUrlTemplate, "exploration", explorationId, imageFileName
)
}
}

internal fun createTopicThumbnail0(): LessonThumbnail {
Expand Down
Loading

0 comments on commit b8100ad

Please sign in to comment.