diff --git a/WORKSPACE b/WORKSPACE index 0c7c57e3f8c..52fbdf3c363 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -129,6 +129,15 @@ git_repository( remote = "https://github.com/oppia/androidsvg", ) +# A custom fork of KotliTeX that removes resources artifacts that break the build, and updates the +# min target SDK version to be compatible with Oppia. +git_repository( + name = "kotlitex", + commit = "6b7db8ff9e0f4a70bdaa25f482143e038fd0c301", + remote = "https://github.com/oppia/kotlitex", + shallow_since = "1647554845 -0700", +) + bind( name = "databinding_annotation_processor", actual = "//tools/android:compiler_annotation_processor", diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 64572260137..e26c3cfb15d 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -782,7 +782,9 @@ kt_android_library( "//utility/src/main/java/org/oppia/android/util/parser/image:image_parsing_module", # TODO(#2432): Replace debug_module with prod_module when building the app in prod mode. "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + "//utility/src/main/java/org/oppia/android/util/logging:console_logger_injector_provider", "//utility/src/main/java/org/oppia/android/util/statusbar:status_bar_color", + "//utility/src/main/java/org/oppia/android/util/threading:dispatcher_injector_provider", ], ) diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt index 39717b2ec30..0d27a11aced 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt @@ -3,11 +3,15 @@ package org.oppia.android.app.application import org.oppia.android.app.translation.AppLanguageApplicationInjector import org.oppia.android.domain.locale.LocaleApplicationInjector import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.logging.ConsoleLoggerInjector import org.oppia.android.util.system.OppiaClockInjector +import org.oppia.android.util.threading.DispatcherInjector /** Injector for application-level dependencies that can't be directly injected where needed. */ interface ApplicationInjector : DataProvidersInjector, AppLanguageApplicationInjector, OppiaClockInjector, - LocaleApplicationInjector + LocaleApplicationInjector, + DispatcherInjector, + ConsoleLoggerInjector diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt index 55c68268d00..4ef6fc55b3a 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt @@ -6,15 +6,21 @@ import org.oppia.android.domain.locale.LocaleApplicationInjector import org.oppia.android.domain.locale.LocaleApplicationInjectorProvider import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.logging.ConsoleLoggerInjector +import org.oppia.android.util.logging.ConsoleLoggerInjectorProvider import org.oppia.android.util.system.OppiaClockInjector import org.oppia.android.util.system.OppiaClockInjectorProvider +import org.oppia.android.util.threading.DispatcherInjector +import org.oppia.android.util.threading.DispatcherInjectorProvider /** Provider for [ApplicationInjector]. The application context will implement this interface. */ interface ApplicationInjectorProvider : DataProvidersInjectorProvider, AppLanguageApplicationInjectorProvider, OppiaClockInjectorProvider, - LocaleApplicationInjectorProvider { + LocaleApplicationInjectorProvider, + DispatcherInjectorProvider, + ConsoleLoggerInjectorProvider { fun getApplicationInjector(): ApplicationInjector override fun getDataProvidersInjector(): DataProvidersInjector = getApplicationInjector() @@ -25,4 +31,8 @@ interface ApplicationInjectorProvider : override fun getOppiaClockInjector(): OppiaClockInjector = getApplicationInjector() override fun getLocaleApplicationInjector(): LocaleApplicationInjector = getApplicationInjector() + + override fun getDispatcherInjector(): DispatcherInjector = getApplicationInjector() + + override fun getConsoleLoggerInjector(): ConsoleLoggerInjector = getApplicationInjector() } diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt index 76956e4d84e..098d29ee351 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt @@ -94,7 +94,11 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING +import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.CacheLatexRendering import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.PlatformParameterSingleton import org.oppia.android.util.platformparameter.PlatformParameterValue import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE @@ -654,6 +658,15 @@ class OptionsFragmentTest { fun provideEnableLanguageSelectionUi(): PlatformParameterValue { return PlatformParameterValue.createDefaultParameter(forceEnableLanguageSelectionUi) } + + @Provides + @CacheLatexRendering + fun provideCacheLatexRendering( + platformParameterSingleton: PlatformParameterSingleton + ): PlatformParameterValue { + return platformParameterSingleton.getBooleanPlatformParameter(CACHE_LATEX_RENDERING) + ?: PlatformParameterValue.createDefaultParameter(CACHE_LATEX_RENDERING_DEFAULT_VALUE) + } } // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. diff --git a/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt b/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt index 6362346e0fa..7a92da4f1f3 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt @@ -548,6 +548,62 @@ class HtmlParserTest { onView(withId(R.id.test_html_content_text_view)).perform(click()) } + @Test + fun testHtmlContent_withMathTag_missingFileName_inlineMode_loadsNonMathModeKotlitexMathSpan() { + val htmlParser = htmlParserFactory.create( + resourceBucketName, + entityType = "", + entityId = "", + imageCenterAlign = true, + ) + activityRule.scenario.runWithActivity { + val textView: TextView = it.findViewById(R.id.test_html_content_text_view) + val htmlResult: Spannable = htmlParser.parseOppiaHtml( + "" + + "", + textView, + supportsLinks = true, + supportsConceptCards = true + ) + textView.text = htmlResult + } + + // The rendering mode should be inline for this render type. + val loadedInlineImages = testGlideImageLoader.getLoadedMathDrawables() + assertThat(loadedInlineImages).hasSize(1) + assertThat(loadedInlineImages.first().rawLatex).isEqualTo("\\frac{2}{5}") + assertThat(loadedInlineImages.first().useInlineRendering).isTrue() + } + + @Test + fun testHtmlContent_withMathTag_missingFileName_blockMode_loadsMathModeKotlitexMathSpan() { + val htmlParser = htmlParserFactory.create( + resourceBucketName, + entityType = "", + entityId = "", + imageCenterAlign = true, + ) + activityRule.scenario.runWithActivity { + val textView: TextView = it.findViewById(R.id.test_html_content_text_view) + val htmlResult: Spannable = htmlParser.parseOppiaHtml( + "" + + "", + textView, + supportsLinks = true, + supportsConceptCards = true + ) + textView.text = htmlResult + } + + // The rendering mode should be non-inline for this render type. + val loadedInlineImages = testGlideImageLoader.getLoadedMathDrawables() + assertThat(loadedInlineImages).hasSize(1) + assertThat(loadedInlineImages.first().rawLatex).isEqualTo("\\frac{2}{5}") + assertThat(loadedInlineImages.first().useInlineRendering).isFalse() + } + @Test fun testHtmlContent_withMathTag_loadsTextSvg() { val htmlParser = htmlParserFactory.create( diff --git a/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt b/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt index 41d04fdc19e..9e1106b1301 100644 --- a/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt @@ -83,7 +83,11 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING +import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.CacheLatexRendering import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi +import org.oppia.android.util.platformparameter.PlatformParameterSingleton import org.oppia.android.util.platformparameter.PlatformParameterValue import org.oppia.android.util.platformparameter.SPLASH_SCREEN_WELCOME_MSG_DEFAULT_VALUE import org.oppia.android.util.platformparameter.SYNC_UP_WORKER_TIME_PERIOD_IN_HOURS_DEFAULT_VALUE @@ -251,6 +255,15 @@ class OptionsFragmentTest { fun provideEnableLanguageSelectionUi(): PlatformParameterValue { return PlatformParameterValue.createDefaultParameter(forceEnableLanguageSelectionUi) } + + @Provides + @CacheLatexRendering + fun provideCacheLatexRendering( + platformParameterSingleton: PlatformParameterSingleton + ): PlatformParameterValue { + return platformParameterSingleton.getBooleanPlatformParameter(CACHE_LATEX_RENDERING) + ?: PlatformParameterValue.createDefaultParameter(CACHE_LATEX_RENDERING_DEFAULT_VALUE) + } } // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. diff --git a/domain/src/main/assets/GJ2rLXRKD5hw_1.json b/domain/src/main/assets/GJ2rLXRKD5hw_1.json index 40e4b2e2875..56a8e13bcde 100644 --- a/domain/src/main/assets/GJ2rLXRKD5hw_1.json +++ b/domain/src/main/assets/GJ2rLXRKD5hw_1.json @@ -4,7 +4,7 @@ "page_contents": { "subtitled_html": { "content_id": "content", - "html": "

Description of subtopic is here.

.

This is sample subtopic with dummy content related to Fractions.

Fractions represent equal parts of a whole or a collection. Fraction of a whole: When we divide a whole into equal parts, each part is a fraction of the whole. A fraction has two parts. The number on the top of the line is called the numerator. It tells how many equal parts of the whole or collection are taken. The number below the line is called the denominator. It shows the total divisible number of equal parts the whole into or the total number of equal parts which are there in a collection." + "html": "

Description of subtopic is here.

.

This is sample subtopic with dummy content related to Fractions: A lot more text that hopefully wraps to the next line in order to see whether the fraction correctly breaks subsequent text lines since we definitely don't want it to overlap since then it would most certainly not be readable in the least.

Fractions represent equal parts of a whole or a collection. Fraction of a whole: When we divide a whole into equal parts, each part is a fraction of the whole. A fraction has two parts. The number on the top of the line is called the numerator. It tells how many equal parts of the whole or collection are taken. The number below the line is called the denominator. It shows the total divisible number of equal parts the whole into or the total number of equal parts which are there in a collection. Also, here's the equation for a line using in-line LaTeX:

" }, "recorded_voiceovers": { "voiceovers_mapping": { diff --git a/domain/src/main/assets/GJ2rLXRKD5hw_1.textproto b/domain/src/main/assets/GJ2rLXRKD5hw_1.textproto index 3d9625b2b4b..80adadf336e 100644 --- a/domain/src/main/assets/GJ2rLXRKD5hw_1.textproto +++ b/domain/src/main/assets/GJ2rLXRKD5hw_1.textproto @@ -1,6 +1,6 @@ subtopic_title: "What is a Fraction?" page_contents { - html: "

Description of subtopic is here.

.

This is sample subtopic with dummy content related to Fractions.

Fractions represent equal parts of a whole or a collection. Fraction of a whole: When we divide a whole into equal parts, each part is a fraction of the whole. A fraction has two parts. The number on the top of the line is called the numerator. It tells how many equal parts of the whole or collection are taken. The number below the line is called the denominator. It shows the total divisible number of equal parts the whole into or the total number of equal parts which are there in a collection." + html: "

Description of subtopic is here.

.

This is sample subtopic with dummy content related to Fractions: A lot more text that hopefully wraps to the next line in order to see whether the fraction correctly breaks subsequent text lines since we definitely don't want it to overlap since then it would most certainly not be readable in the least.

Fractions represent equal parts of a whole or a collection. Fraction of a whole: When we divide a whole into equal parts, each part is a fraction of the whole. A fraction has two parts. The number on the top of the line is called the numerator. It tells how many equal parts of the whole or collection are taken. The number below the line is called the denominator. It shows the total divisible number of equal parts the whole into or the total number of equal parts which are there in a collection. Also, here's the equation for a line using in-line LaTeX:

" content_id: "content" } recorded_voiceover { diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt index 43916e0b513..c228216d7f0 100644 --- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt +++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterModule.kt @@ -2,6 +2,9 @@ package org.oppia.android.domain.platformparameter import dagger.Module import dagger.Provides +import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING +import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.CacheLatexRendering import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS @@ -56,4 +59,13 @@ class PlatformParameterModule { return platformParameterSingleton.getBooleanPlatformParameter(LEARNER_STUDY_ANALYTICS) ?: PlatformParameterValue.createDefaultParameter(LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE) } + + @Provides + @CacheLatexRendering + fun provideCacheLatexRendering( + platformParameterSingleton: PlatformParameterSingleton + ): PlatformParameterValue { + return platformParameterSingleton.getBooleanPlatformParameter(CACHE_LATEX_RENDERING) + ?: PlatformParameterValue.createDefaultParameter(CACHE_LATEX_RENDERING_DEFAULT_VALUE) + } } diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index e1dbfb866ec..469bf3439e5 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -714,6 +714,8 @@ exempted_file_path: "utility/src/main/java/org/oppia/android/util/gcsresource/Gc exempted_file_path: "utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatter.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/ConsoleLogger.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjector.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjectorProvider.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/EventLogger.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/ExceptionLogger.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/LogLevel.kt" @@ -751,6 +753,7 @@ exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/image/R exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/image/RepositoryModelLoader.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/image/TestGlideImageLoader.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/image/TextPictureDrawable.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/math/MathBitmapModelLoader.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/svg/BlockPictureDrawable.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/svg/BlockSvgDrawableTranscoder.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/parser/svg/ScalableVectorGraphic.kt" @@ -769,4 +772,6 @@ exempted_file_path: "utility/src/main/java/org/oppia/android/util/system/OppiaCl exempted_file_path: "utility/src/main/java/org/oppia/android/util/threading/BackgroundDispatcher.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/threading/BlockingDispatcher.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/threading/ConcurrentCollections.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/threading/DispatcherInjector.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/threading/DispatcherInjectorProvider.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/threading/DispatcherModule.kt" diff --git a/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt b/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt index 2df225f632f..b6071d86b76 100644 --- a/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt +++ b/scripts/src/java/org/oppia/android/scripts/common/BazelClient.kt @@ -2,6 +2,7 @@ package org.oppia.android.scripts.common import java.io.File import java.lang.IllegalArgumentException +import java.util.Locale /** * Utility class to query & interact with a Bazel workspace on the local filesystem (residing within @@ -21,11 +22,11 @@ class BazelClient( /** Returns all Bazel file targets that correspond to each of the relative file paths provided. */ fun retrieveBazelTargets(changedFileRelativePaths: Iterable): List { return correctPotentiallyBrokenTargetNames( - executeBazelCommand( - "query", + runPotentiallyShardedQueryCommand( + "set(%s)", + changedFileRelativePaths, "--noshow_progress", "--keep_going", - "set(${changedFileRelativePaths.joinToString(" ")})", allowPartialFailures = true ) ) @@ -34,12 +35,12 @@ class BazelClient( /** Returns all test targets in the workspace that are affected by the list of file targets. */ fun retrieveRelatedTestTargets(fileTargets: Iterable): List { return correctPotentiallyBrokenTargetNames( - executeBazelCommand( - "query", + runPotentiallyShardedQueryCommand( + "kind(test, allrdeps(set(%s)))", + fileTargets, "--noshow_progress", "--universe_scope=//...", - "--order_output=no", - "kind(test, allrdeps(set(${fileTargets.joinToString(" ")})))" + "--order_output=no" ) ) } @@ -71,12 +72,12 @@ class BazelClient( retrieveFilteredSiblings(filterRuleType = "android_library", buildFileTarget) }.toSet() return correctPotentiallyBrokenTargetNames( - executeBazelCommand( - "query", + runPotentiallyShardedQueryCommand( + "filter('^[^@]', kind(test, allrdeps(set(%s))))", + relevantSiblings, "--noshow_progress", "--universe_scope=//...", "--order_output=no", - "filter('^[^@]', kind(test, allrdeps(set(${relevantSiblings.joinToString(" ")}))))", ) ) } else listOf() @@ -129,6 +130,43 @@ class BazelClient( return correctedTargets } + /** + * Returns the results of a query command with a potentially large list of [values] that will be + * split up into multiple commands to avoid overflow the system's maximum argument limit. + * + * Note that [queryFormatStr] is expected to have 1 string variable (which will be the + * space-separated join of [values] or a partition of [values]). + */ + @Suppress("SameParameterValue") // This check doesn't work correctly for varargs. + private fun runPotentiallyShardedQueryCommand( + queryFormatStr: String, + values: Iterable, + vararg prefixArgs: String, + allowPartialFailures: Boolean = false + ): List { + // Split up values into partitions to ensure that the argument calls don't over-run the limit. + var partitionCount = 0 + lateinit var partitions: List> + do { + partitionCount++ + partitions = values.chunked((values.count() + 1) / partitionCount) + } while (computeMaxArgumentLength(partitions) >= MAX_ALLOWED_ARG_STR_LENGTH) + + // Fragment the query across the partitions to ensure all values can be considered. + return partitions.flatMap { partition -> + val lastArgument = queryFormatStr.format(Locale.US, partition.joinToString(" ")) + val allArguments = prefixArgs.toList() + lastArgument + executeBazelCommand( + "query", *allArguments.toTypedArray(), allowPartialFailures = allowPartialFailures + ) + } + } + + private fun computeMaxArgumentLength(partitions: List>) = + partitions.map(this::computeArgumentLength).maxOrNull() ?: 0 + + private fun computeArgumentLength(args: List) = args.joinToString(" ").length + @Suppress("SameParameterValue") // This check doesn't work correctly for varargs. private fun executeBazelCommand( vararg arguments: String, @@ -150,6 +188,10 @@ class BazelClient( } return result.output } + + private companion object { + private const val MAX_ALLOWED_ARG_STR_LENGTH = 50_000 + } } /** Returns a list of indexes where the specified [needle] occurs in this string. */ diff --git a/third_party/BUILD.bazel b/third_party/BUILD.bazel index 860faf7687e..ade7145d461 100644 --- a/third_party/BUILD.bazel +++ b/third_party/BUILD.bazel @@ -57,6 +57,7 @@ android_library( android_library( name = "robolectric_android-all", + testonly = True, visibility = ["//visibility:public"], exports = [ "@robolectric//bazel:android-all", @@ -73,6 +74,14 @@ java_library( ], ) +android_library( + name = "io_github_karino2_kotlitex", + visibility = ["//visibility:public"], + exports = [ + "@kotlitex//kotlitex", + ], +) + # Define a separate target for the Glide annotation processor compiler. Unfortunately, this library # can't encapsulate all of Glide (i.e. by exporting the main Glide dependency) since that includes # Android assets which java_library targets do not export. diff --git a/utility/build.gradle b/utility/build.gradle index dd97adf9aa2..99271dbc3ae 100644 --- a/utility/build.gradle +++ b/utility/build.gradle @@ -65,6 +65,7 @@ dependencies { 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03', 'androidx.work:work-runtime-ktx:2.4.0', 'com.github.oppia:androidsvg:6bd15f69caee3e6857fcfcd123023716b4adec1d', + 'com.github.oppia:kotlitex:6b7db8ff9e0f4a70bdaa25f482143e038fd0c301', 'com.github.bumptech.glide:glide:4.11.0', 'com.google.dagger:dagger:2.24', 'com.google.firebase:firebase-analytics-ktx:17.5.0', diff --git a/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel index 21b2fa9154c..4e9ed4bd036 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/logging/BUILD.bazel @@ -32,6 +32,32 @@ kt_android_library( ], ) +kt_android_library( + name = "console_logger_injector", + srcs = [ + "ConsoleLoggerInjector.kt", + ], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + ":console_logger", + ], +) + +kt_android_library( + name = "console_logger_injector_provider", + srcs = [ + "ConsoleLoggerInjectorProvider.kt", + ], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + ":console_logger_injector", + ], +) + kt_android_library( name = "event_bundle_creator", srcs = [ diff --git a/utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjector.kt b/utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjector.kt new file mode 100644 index 00000000000..0c2f2c42b2d --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjector.kt @@ -0,0 +1,7 @@ +package org.oppia.android.util.logging + +/** Injector for [ConsoleLogger]. Implemented by a generated Dagger application component. */ +interface ConsoleLoggerInjector { + /** Returns the application-level [ConsoleLogger]. */ + fun getConsoleLogger(): ConsoleLogger +} diff --git a/utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjectorProvider.kt b/utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjectorProvider.kt new file mode 100644 index 00000000000..8391577f9ea --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/logging/ConsoleLoggerInjectorProvider.kt @@ -0,0 +1,7 @@ +package org.oppia.android.util.logging + +/** Provider for [ConsoleLoggerInjector]s. To be implemented by the application class. */ +interface ConsoleLoggerInjectorProvider { + /** Returns the [ConsoleLoggerInjector] corresponding to the current application context. */ + fun getConsoleLoggerInjector(): ConsoleLoggerInjector +} diff --git a/utility/src/main/java/org/oppia/android/util/parser/html/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/parser/html/BUILD.bazel index 946f880582e..0f488770ddf 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/html/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/parser/html/BUILD.bazel @@ -33,6 +33,7 @@ kt_android_library( visibility = ["//utility:__subpackages__"], deps = [ ":custom_html_content_handler", + "//third_party:io_github_karino2_kotlitex", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", ], ) diff --git a/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt b/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt index c19f35f61f3..ea2011ba47b 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt @@ -173,6 +173,12 @@ class CustomHtmlContentHandler private constructor( /** Returns a new [Drawable] corresponding to the specified image filename and [Type]. */ fun loadDrawable(filename: String, type: Type): Drawable + /** + * Returns a new [Drawable] representing a cached render of the specified [rawLatex] for the + * given [lineHeight] and for the rendering [type]. + */ + fun loadMathDrawable(rawLatex: String, lineHeight: Float, type: Type): Drawable + /** Corresponds to the types of images that can be retrieved. */ enum class Type { /** diff --git a/utility/src/main/java/org/oppia/android/util/parser/html/HtmlParser.kt b/utility/src/main/java/org/oppia/android/util/parser/html/HtmlParser.kt index 41606b659cb..b21abbc8bca 100755 --- a/utility/src/main/java/org/oppia/android/util/parser/html/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/html/HtmlParser.kt @@ -1,5 +1,6 @@ package org.oppia.android.util.parser.html +import android.content.Context import android.text.Spannable import android.text.SpannableStringBuilder import android.text.method.LinkMovementMethod @@ -8,16 +9,20 @@ import android.widget.TextView import androidx.core.view.ViewCompat import org.oppia.android.util.logging.ConsoleLogger import org.oppia.android.util.parser.image.UrlImageParser +import org.oppia.android.util.platformparameter.CacheLatexRendering +import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject /** Html Parser to parse custom Oppia tags with Android-compatible versions. */ class HtmlParser private constructor( + private val context: Context, private val urlImageParserFactory: UrlImageParser.Factory, private val gcsResourceName: String, private val entityType: String, private val entityId: String, private val imageCenterAlign: Boolean, private val consoleLogger: ConsoleLogger, + private val cacheLatexRendering: Boolean, customOppiaTagActionListener: CustomOppiaTagActionListener? ) { private val conceptCardTagHandler by lazy { @@ -32,7 +37,6 @@ class HtmlParser private constructor( } private val bulletTagHandler by lazy { BulletTagHandler() } private val imageTagHandler by lazy { ImageTagHandler(consoleLogger) } - private val mathTagHandler by lazy { MathTagHandler(consoleLogger) } /** * Parses a raw HTML string with support for custom Oppia tags. @@ -84,7 +88,7 @@ class HtmlParser private constructor( htmlContentTextView, gcsResourceName, entityType, entityId, imageCenterAlign ) val htmlSpannable = CustomHtmlContentHandler.fromHtml( - htmlContent, imageGetter, computeCustomTagHandlers(supportsConceptCards) + htmlContent, imageGetter, computeCustomTagHandlers(supportsConceptCards, htmlContentTextView) ) val spannableBuilder = CustomBulletSpan.replaceBulletSpan( @@ -99,12 +103,19 @@ class HtmlParser private constructor( } private fun computeCustomTagHandlers( - supportsConceptCards: Boolean + supportsConceptCards: Boolean, + htmlContentTextView: TextView ): Map { val handlersMap = mutableMapOf() handlersMap[CUSTOM_BULLET_LIST_TAG] = bulletTagHandler handlersMap[CUSTOM_IMG_TAG] = imageTagHandler - handlersMap[CUSTOM_MATH_TAG] = mathTagHandler + handlersMap[CUSTOM_MATH_TAG] = + MathTagHandler( + consoleLogger, + context.assets, + htmlContentTextView.lineHeight.toFloat(), + cacheLatexRendering + ) if (supportsConceptCards) { handlersMap[CUSTOM_CONCEPT_CARD_TAG] = conceptCardTagHandler } @@ -143,7 +154,9 @@ class HtmlParser private constructor( /** Factory for creating new [HtmlParser]s. */ class Factory @Inject constructor( private val urlImageParserFactory: UrlImageParser.Factory, - private val consoleLogger: ConsoleLogger + private val consoleLogger: ConsoleLogger, + private val context: Context, + @CacheLatexRendering private val enableCacheLatexRendering: PlatformParameterValue ) { /** * Returns a new [HtmlParser] with the specified entity type and ID for loading images, and an @@ -157,12 +170,14 @@ class HtmlParser private constructor( customOppiaTagActionListener: CustomOppiaTagActionListener? = null ): HtmlParser { return HtmlParser( + context, urlImageParserFactory, gcsResourceName, entityType, entityId, imageCenterAlign, consoleLogger, + cacheLatexRendering = enableCacheLatexRendering.value, customOppiaTagActionListener ) } diff --git a/utility/src/main/java/org/oppia/android/util/parser/html/MathTagHandler.kt b/utility/src/main/java/org/oppia/android/util/parser/html/MathTagHandler.kt index 0e2329e1bae..cbcdba0c35c 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/html/MathTagHandler.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/html/MathTagHandler.kt @@ -1,22 +1,30 @@ package org.oppia.android.util.parser.html +import android.content.res.AssetManager import android.text.Editable import android.text.Spannable import android.text.style.ImageSpan +import io.github.karino2.kotlitex.view.MathExpressionSpan import org.json.JSONObject import org.oppia.android.util.logging.ConsoleLogger +import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever.Type.BLOCK_IMAGE +import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever.Type.INLINE_TEXT_IMAGE import org.xml.sax.Attributes /** The custom tag corresponding to [MathTagHandler]. */ const val CUSTOM_MATH_TAG = "oppia-noninteractive-math" -private const val CUSTOM_MATH_SVG_PATH_ATTRIBUTE = "math_content-with-value" +private const val CUSTOM_MATH_MATH_CONTENT_ATTRIBUTE = "math_content-with-value" +private const val CUSTOM_MATH_RENDER_TYPE_ATTRIBUTE = "render-type" /** * A custom tag handler for properly formatting math items in HTML parsed with * [CustomHtmlContentHandler]. */ class MathTagHandler( - private val consoleLogger: ConsoleLogger + private val consoleLogger: ConsoleLogger, + private val assetManager: AssetManager, + private val lineHeight: Float, + private val cacheLatexRendering: Boolean ) : CustomHtmlContentHandler.CustomTagHandler { override fun handleTag( attributes: Attributes, @@ -27,41 +35,77 @@ class MathTagHandler( ) { // Only insert the image tag if it's parsed correctly. val content = MathContent.parseMathContent( - attributes.getJsonObjectValue(CUSTOM_MATH_SVG_PATH_ATTRIBUTE) + attributes.getJsonObjectValue(CUSTOM_MATH_MATH_CONTENT_ATTRIBUTE) ) - if (content != null) { - // Insert an image span where the custom tag currently is to load the SVG. In the future, this - // could also load a LaTeX span, instead. Note that this approach is based on Android's Html - // parser. - val drawable = - imageRetriever.loadDrawable( - content.svgFilename, - CustomHtmlContentHandler.ImageRetriever.Type.INLINE_TEXT_IMAGE + // TODO(#4170): Fix vertical alignment centering for inline cached LaTeX. + val useInlineRendering = when (attributes.getValue(CUSTOM_MATH_RENDER_TYPE_ATTRIBUTE)) { + "inline" -> true + "block" -> false + else -> true + } + val newSpan = when (content) { + is MathContent.MathAsSvg -> { + ImageSpan( + imageRetriever.loadDrawable( + content.svgFilename, + INLINE_TEXT_IMAGE + ), + content.svgFilename ) - val (startIndex, endIndex) = output.run { - // Use a control character to ensure that there's at least 1 character on which to "attach" - // the image when rendering the HTML. - val startIndex = length - append('\uFFFC') - return@run startIndex to length } - output.setSpan( - ImageSpan(drawable, content.svgFilename), - startIndex, - endIndex, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } else consoleLogger.e("MathTagHandler", "Failed to parse math tag") + is MathContent.MathAsLatex -> { + if (cacheLatexRendering) { + ImageSpan( + imageRetriever.loadMathDrawable( + content.rawLatex, + lineHeight, + type = if (useInlineRendering) INLINE_TEXT_IMAGE else BLOCK_IMAGE + ) + ) + } else { + MathExpressionSpan( + content.rawLatex, lineHeight, assetManager, isMathMode = !useInlineRendering + ) + } + } + null -> { + consoleLogger.e("MathTagHandler", "Failed to parse math tag") + return + } + } + + // Insert an image span where the custom tag currently is to load the SVG/LaTeX span. Note that + // this approach is based on Android's HTML parser. + val (startIndex, endIndex) = output.run { + // Use a control character to ensure that there's at least 1 character on which to + // "attach" the image when rendering the HTML. + val startIndex = length + append('\uFFFC') + return@run startIndex to length + } + output.setSpan( + newSpan, + startIndex, + endIndex, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) } - private data class MathContent(val rawLatex: String, val svgFilename: String) { + private sealed class MathContent { + data class MathAsSvg(val svgFilename: String) : MathContent() + + data class MathAsLatex(val rawLatex: String) : MathContent() + companion object { internal fun parseMathContent(obj: JSONObject?): MathContent? { + // Kotlitex expects escaped backslashes. val rawLatex = obj?.getOptionalString("raw_latex") val svgFilename = obj?.getOptionalString("svg_filename") - return if (rawLatex != null && svgFilename != null) { - MathContent(rawLatex, svgFilename) - } else null + return when { + svgFilename != null -> MathAsSvg(svgFilename) + rawLatex != null -> MathAsLatex(rawLatex) + else -> null + } } /** diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/parser/image/BUILD.bazel index f12bd0fedb6..bdc56d21a08 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/parser/image/BUILD.bazel @@ -42,6 +42,7 @@ kt_android_library( "//third_party:javax_inject_javax_inject", "//utility/src/main/java/org/oppia/android/util/caching:annotations", "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + "//utility/src/main/java/org/oppia/android/util/parser/math:math_latex_model", "//utility/src/main/java/org/oppia/android/util/parser/svg:block_picture_drawable", "//utility/src/main/java/org/oppia/android/util/parser/svg:scalable_vector_graphic", "//utility/src/main/java/org/oppia/android/util/parser/svg:svg_blur_transformation", @@ -151,6 +152,7 @@ kt_android_library( "//third_party:glide_compiler", "//utility/src/main/java/org/oppia/android/util/caching:annotations", "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + "//utility/src/main/java/org/oppia/android/util/parser/math:math_bitmap_model_loader", "//utility/src/main/java/org/oppia/android/util/parser/svg:block_picture_drawable", "//utility/src/main/java/org/oppia/android/util/parser/svg:block_svg_drawable_transcoder", "//utility/src/main/java/org/oppia/android/util/parser/svg:scalable_vector_graphic", diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/GlideImageLoader.kt b/utility/src/main/java/org/oppia/android/util/parser/image/GlideImageLoader.kt index 10e91bad48f..c70575d97dc 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/GlideImageLoader.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/GlideImageLoader.kt @@ -11,6 +11,7 @@ import com.bumptech.glide.request.RequestOptions import org.oppia.android.util.caching.AssetRepository import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadImagesFromAssets +import org.oppia.android.util.parser.math.MathModel import org.oppia.android.util.parser.svg.BlockPictureDrawable import org.oppia.android.util.parser.svg.ScalableVectorGraphic import org.oppia.android.util.parser.svg.SvgBlurTransformation @@ -68,6 +69,18 @@ class GlideImageLoader @Inject constructor( .intoTarget(target) } + override fun loadMathDrawable( + rawLatex: String, + lineHeight: Float, + useInlineRendering: Boolean, + target: ImageTarget + ) { + glide + .asBitmap() + .load(MathModel(rawLatex, lineHeight, useInlineRendering)) + .intoTarget(target) + } + private inline fun loadSvgWithGlide( imageUrl: String, target: ImageTarget, diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/ImageLoader.kt b/utility/src/main/java/org/oppia/android/util/parser/image/ImageLoader.kt index 30963c9f3d6..8ae318b67fb 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/ImageLoader.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/ImageLoader.kt @@ -30,8 +30,8 @@ interface ImageLoader { ) /** - * Same as [loadBlockSvg] except this specifically loads a [TextPictureDrawable] which can be rendered - * in-line with text. + * Same as [loadBlockSvg] except this specifically loads a [TextPictureDrawable] which can be + * rendered in-line with text. */ fun loadTextSvg( imageUrl: String, @@ -40,7 +40,8 @@ interface ImageLoader { ) /** - * Loads the specified [imageDrawable] resource into the specified [target]. + * Loads the specified [imageDrawableResId] resource into the specified [target]. + * * Optional [transformations] may be applied to the image. */ fun loadDrawable( @@ -48,4 +49,16 @@ interface ImageLoader { target: ImageTarget, transformations: List = listOf() ) + + /** + * Loads the specified cached math [rawLatex] into the specified [target] with the provided font + * [lineHeight] setting and details on how the image will be displayed indicated by + * [useInlineRendering]. + */ + fun loadMathDrawable( + rawLatex: String, + lineHeight: Float, + useInlineRendering: Boolean, + target: ImageTarget + ) } diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt b/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt index b11652e0207..d2349ab3eec 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt @@ -5,12 +5,15 @@ import com.bumptech.glide.Glide import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.module.AppGlideModule +import org.oppia.android.util.parser.math.MathBitmapModelLoader +import org.oppia.android.util.parser.math.MathModel import org.oppia.android.util.parser.svg.BlockPictureDrawable import org.oppia.android.util.parser.svg.BlockSvgDrawableTranscoder import org.oppia.android.util.parser.svg.ScalableVectorGraphic import org.oppia.android.util.parser.svg.SvgDecoder import org.oppia.android.util.parser.svg.TextSvgDrawableTranscoder import java.io.InputStream +import java.nio.ByteBuffer /** * Custom [AppGlideModule] to enable loading images from @@ -37,5 +40,11 @@ class RepositoryGlideModule : AppGlideModule() { InputStream::class.java, RepositoryModelLoader.Factory() ) + + registry.append( + MathModel::class.java, + ByteBuffer::class.java, + MathBitmapModelLoader.Factory(context.applicationContext) + ) } } diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/TestGlideImageLoader.kt b/utility/src/main/java/org/oppia/android/util/parser/image/TestGlideImageLoader.kt index 800b7b5b566..82acfc150b6 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/TestGlideImageLoader.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/TestGlideImageLoader.kt @@ -2,6 +2,7 @@ package org.oppia.android.util.parser.image import android.graphics.Bitmap import android.graphics.drawable.Drawable +import org.oppia.android.util.parser.math.MathModel import org.oppia.android.util.parser.svg.BlockPictureDrawable import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +19,7 @@ class TestGlideImageLoader @Inject constructor( private val loadedBitmaps = mutableListOf() private val loadedBlockSvgs = mutableListOf() private val loadedTextSvgs = mutableListOf() + private val loadedMathDrawables = mutableListOf() override fun loadBitmap( imageUrl: String, @@ -62,6 +64,16 @@ class TestGlideImageLoader @Inject constructor( } } + override fun loadMathDrawable( + rawLatex: String, + lineHeight: Float, + useInlineRendering: Boolean, + target: ImageTarget + ) { + loadedMathDrawables += MathModel(rawLatex, lineHeight, useInlineRendering) + glideImageLoader.loadMathDrawable(rawLatex, lineHeight, useInlineRendering, target) + } + /** * Returns the list of image URLs that have been loaded as bitmaps since the start of the * application. @@ -79,4 +91,7 @@ class TestGlideImageLoader @Inject constructor( * start of the application. */ fun getLoadedTextSvgs(): List = loadedTextSvgs + + /** Returns the list of renderable math LaTeX [MathModel]s that have been loaded as drawables. */ + fun getLoadedMathDrawables(): List = loadedMathDrawables } diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt b/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt index f5a2567e493..87960a83faa 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt @@ -18,8 +18,9 @@ import com.bumptech.glide.request.transition.Transition import org.oppia.android.util.R import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.util.logging.ConsoleLogger -import org.oppia.android.util.parser.html.CustomHtmlContentHandler import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever +import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever.Type.BLOCK_IMAGE +import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever.Type.INLINE_TEXT_IMAGE import org.oppia.android.util.parser.svg.BlockPictureDrawable import javax.inject.Inject import kotlin.math.max @@ -39,10 +40,10 @@ class UrlImageParser private constructor( private val imageLoader: ImageLoader, private val consoleLogger: ConsoleLogger, private val machineLocale: OppiaLocale.MachineLocale -) : Html.ImageGetter, CustomHtmlContentHandler.ImageRetriever { +) : Html.ImageGetter, ImageRetriever { override fun getDrawable(urlString: String): Drawable { // Only block images can be loaded through the standard ImageGetter. - return loadDrawable(urlString, ImageRetriever.Type.BLOCK_IMAGE) + return loadDrawable(urlString, BLOCK_IMAGE) } override fun loadDrawable(filename: String, type: ImageRetriever.Type): Drawable { @@ -53,21 +54,23 @@ class UrlImageParser private constructor( val proxyDrawable = ProxyDrawable() // TODO(#1039): Introduce custom type OppiaImage for rendering Bitmap and Svg. val isSvg = machineLocale.run { imageUrl.endsWithIgnoreCase("svg") } - val adjustedType = if (type == ImageRetriever.Type.INLINE_TEXT_IMAGE && !isSvg) { + val adjustedType = if (type == INLINE_TEXT_IMAGE && !isSvg) { // Treat non-svg in-line images as block, instead, since only SVG is supported. consoleLogger.w("UrlImageParser", "Forcing image $filename to block image") - ImageRetriever.Type.BLOCK_IMAGE + BLOCK_IMAGE } else type return when (adjustedType) { - ImageRetriever.Type.INLINE_TEXT_IMAGE -> { + INLINE_TEXT_IMAGE -> { imageLoader.loadTextSvg( imageUrl, - createCustomTarget(proxyDrawable, AutoAdjustingImageTarget.TextSvgTarget::create) + createCustomTarget(proxyDrawable) { + AutoAdjustingImageTarget.InlineTextImage.createForSvg(it) + } ) proxyDrawable } - ImageRetriever.Type.BLOCK_IMAGE -> { + BLOCK_IMAGE -> { if (isSvg) { imageLoader.loadBlockSvg( imageUrl, @@ -90,6 +93,32 @@ class UrlImageParser private constructor( } } + override fun loadMathDrawable( + rawLatex: String, + lineHeight: Float, + type: ImageRetriever.Type + ): Drawable { + return ProxyDrawable().also { drawable -> + imageLoader.loadMathDrawable( + rawLatex, + lineHeight, + useInlineRendering = type == INLINE_TEXT_IMAGE, + createCustomTarget(drawable) { + when (type) { + INLINE_TEXT_IMAGE -> AutoAdjustingImageTarget.InlineTextImage.createForMath(context, it) + BLOCK_IMAGE -> { + // Render the LaTeX as a block image, but don't automatically resize it since it's + // text (which means resizing may make it unreadable). + AutoAdjustingImageTarget.BlockImageTarget.BitmapTarget.create( + it, autoResizeImage = false + ) + } + } + } + ) + } + } + private fun > createCustomTarget( proxyDrawable: ProxyDrawable, createTarget: (AutoAdjustingImageTarget.TargetConfiguration) -> C @@ -144,7 +173,8 @@ class UrlImageParser private constructor( * display them in a "block" fashion. */ sealed class BlockImageTarget( - targetConfiguration: TargetConfiguration + targetConfiguration: TargetConfiguration, + private val autoResizeImage: Boolean ) : AutoAdjustingImageTarget(targetConfiguration) { override fun computeBounds(drawable: D, viewWidth: Int): Rect { @@ -162,40 +192,42 @@ class UrlImageParser private constructor( var drawableWidth = drawable.intrinsicWidth.toFloat() var drawableHeight = drawable.intrinsicHeight.toFloat() - val minimumImageSize = context.resources.getDimensionPixelSize(R.dimen.minimum_image_size) - if (drawableHeight <= minimumImageSize || drawableWidth <= minimumImageSize) { - // The multipleFactor value is used to make sure that the aspect ratio of the image - // remains the same. - // Example: Height is 90, width is 60 and minimumImageSize is 120. - // Then multipleFactor will be 2 (120/60). - // The new height will be 180 and new width will be 120. - val multipleFactor = if (drawableHeight <= drawableWidth) { - // If height is less then the width, multipleFactor value is determined by height. - minimumImageSize.toFloat() / drawableHeight - } else { - // If height is less then the width, multipleFactor value is determined by width. - minimumImageSize.toFloat() / drawableWidth + if (autoResizeImage) { + val minimumImageSize = context.resources.getDimensionPixelSize(R.dimen.minimum_image_size) + if (drawableHeight <= minimumImageSize || drawableWidth <= minimumImageSize) { + // The multipleFactor value is used to make sure that the aspect ratio of the image + // remains the same. + // Example: Height is 90, width is 60 and minimumImageSize is 120. + // Then multipleFactor will be 2 (120/60). + // The new height will be 180 and new width will be 120. + val multipleFactor = if (drawableHeight <= drawableWidth) { + // If height is less then the width, multipleFactor value is determined by height. + minimumImageSize.toFloat() / drawableHeight + } else { + // If height is less then the width, multipleFactor value is determined by width. + minimumImageSize.toFloat() / drawableWidth + } + drawableHeight *= multipleFactor + drawableWidth *= multipleFactor } - drawableHeight *= multipleFactor - drawableWidth *= multipleFactor - } - val maxContentItemPadding = - context.resources.getDimensionPixelSize(R.dimen.maximum_content_item_padding) - val maximumImageSize = maxAvailableWidth - maxContentItemPadding - if (drawableWidth >= maximumImageSize) { - // The multipleFactor value is used to make sure that the aspect ratio of the image - // remains the same. Example: Height is 420, width is 440 and maximumImageSize is 200. - // Then multipleFactor will be (200/440). The new height will be 191 and new width will - // be 200. - val multipleFactor = if (drawableHeight >= drawableWidth) { - // If height is greater then the width, multipleFactor value is determined by height. - (maximumImageSize.toFloat() / drawableHeight) - } else { - // If height is greater then the width, multipleFactor value is determined by width. - (maximumImageSize.toFloat() / drawableWidth) + val maxContentItemPadding = + context.resources.getDimensionPixelSize(R.dimen.maximum_content_item_padding) + val maximumImageSize = maxAvailableWidth - maxContentItemPadding + if (drawableWidth >= maximumImageSize) { + // The multipleFactor value is used to make sure that the aspect ratio of the image + // remains the same. Example: Height is 420, width is 440 and maximumImageSize is 200. + // Then multipleFactor will be (200/440). The new height will be 191 and new width will + // be 200. + val multipleFactor = if (drawableHeight >= drawableWidth) { + // If height is greater then the width, multipleFactor value is determined by height. + (maximumImageSize.toFloat() / drawableHeight) + } else { + // If height is greater then the width, multipleFactor value is determined by width. + (maximumImageSize.toFloat() / drawableWidth) + } + drawableHeight *= multipleFactor + drawableWidth *= multipleFactor } - drawableHeight *= multipleFactor - drawableWidth *= multipleFactor } val drawableLeft = if (imageCenterAlign) { calculateInitialMargin(maxAvailableWidth, drawableWidth) @@ -213,7 +245,9 @@ class UrlImageParser private constructor( /** A [BlockImageTarget] used to load & arrange SVGs. */ internal class SvgTarget( targetConfiguration: TargetConfiguration - ) : BlockImageTarget(targetConfiguration) { + ) : BlockImageTarget( + targetConfiguration, autoResizeImage = true + ) { override fun retrieveDrawable(resource: BlockPictureDrawable): BlockPictureDrawable = resource @@ -225,40 +259,63 @@ class UrlImageParser private constructor( /** A [BlockImageTarget] used to load & arrange bitmaps. */ internal class BitmapTarget( - targetConfiguration: TargetConfiguration - ) : BlockImageTarget(targetConfiguration) { + targetConfiguration: TargetConfiguration, + autoResizeImage: Boolean + ) : BlockImageTarget(targetConfiguration, autoResizeImage) { override fun retrieveDrawable(resource: Bitmap): BitmapDrawable { return BitmapDrawable(context.resources, resource) } companion object { /** Returns a new [BitmapTarget] for the specified configuration. */ - fun create(targetConfiguration: TargetConfiguration) = BitmapTarget(targetConfiguration) + fun create(targetConfiguration: TargetConfiguration, autoResizeImage: Boolean = true) = + BitmapTarget(targetConfiguration, autoResizeImage) } } } /** - * A [AutoAdjustingImageTarget] that should be used for in-line SVG images that will not be - * resized or aligned beyond what the SVG itself requires, and what the system performs - * automatically. + * A [AutoAdjustingImageTarget] that should be used for in-line SVG images and math expressions + * that will not be resized or aligned beyond what the target itself requires, and what the + * system performs automatically. */ - class TextSvgTarget( - targetConfiguration: TargetConfiguration - ) : AutoAdjustingImageTarget(targetConfiguration) { - override fun retrieveDrawable(resource: TextPictureDrawable): TextPictureDrawable = resource - - override fun computeBounds( - drawable: TextPictureDrawable, - viewWidth: Int - ): Rect { - drawable.computeTextPicture(htmlContentTextView.paint) + class InlineTextImage( + targetConfiguration: TargetConfiguration, + private val computeDrawable: (T) -> D, + private val computeDimensions: (D, TextView) -> Unit, + ) : AutoAdjustingImageTarget(targetConfiguration) { + override fun retrieveDrawable(resource: T): D = computeDrawable(resource) + + override fun computeBounds(drawable: D, viewWidth: Int): Rect { + computeDimensions(drawable, htmlContentTextView) return Rect(/* left= */ 0, /* top= */ 0, drawable.intrinsicWidth, drawable.intrinsicHeight) } companion object { - /** Returns a new [TextSvgTarget] for the specified configuration. */ - fun create(targetConfiguration: TargetConfiguration) = TextSvgTarget(targetConfiguration) + /** Returns a new [InlineTextImage] for the specified SVG configuration. */ + fun createForSvg( + targetConfiguration: TargetConfiguration + ): InlineTextImage { + return InlineTextImage( + targetConfiguration, + computeDrawable = { it }, + computeDimensions = { drawable, textView -> + drawable.computeTextPicture(textView.paint) + } + ) + } + + /** Returns a new [InlineTextImage] for the specified math configuration. */ + fun createForMath( + applicationContext: Context, + targetConfiguration: TargetConfiguration + ): InlineTextImage { + return InlineTextImage( + targetConfiguration, + computeDrawable = { BitmapDrawable(applicationContext.resources, it) }, + computeDimensions = { _, _ -> } + ) + } } } diff --git a/utility/src/main/java/org/oppia/android/util/parser/math/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/parser/math/BUILD.bazel new file mode 100644 index 00000000000..2beb8af1107 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/parser/math/BUILD.bazel @@ -0,0 +1,34 @@ +""" +Components required to render LaTeX math expressions through Glide. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "math_latex_model", + srcs = [ + "MathModel.kt", + ], + visibility = [ + "//:oppia_testing_visibility", + "//utility/src/main/java/org/oppia/android/util/parser/image:__pkg__", + ], + deps = [ + "//third_party:com_github_bumptech_glide_glide", + ], +) + +kt_android_library( + name = "math_bitmap_model_loader", + srcs = [ + "MathBitmapModelLoader.kt", + ], + visibility = ["//utility/src/main/java/org/oppia/android/util/parser/image:__pkg__"], + deps = [ + ":math_latex_model", + "//third_party:com_github_bumptech_glide_glide", + "//third_party:io_github_karino2_kotlitex", + "//utility/src/main/java/org/oppia/android/util/logging:console_logger_injector_provider", + "//utility/src/main/java/org/oppia/android/util/threading:dispatcher_injector_provider", + ], +) diff --git a/utility/src/main/java/org/oppia/android/util/parser/math/MathBitmapModelLoader.kt b/utility/src/main/java/org/oppia/android/util/parser/math/MathBitmapModelLoader.kt new file mode 100644 index 00000000000..e53cd82e71b --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/parser/math/MathBitmapModelLoader.kt @@ -0,0 +1,396 @@ +package org.oppia.android.util.parser.math + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.text.Layout +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.StaticLayout +import android.text.TextPaint +import com.bumptech.glide.Priority +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.data.DataFetcher +import com.bumptech.glide.load.model.ModelLoader +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.load.model.MultiModelLoaderFactory +import com.bumptech.glide.request.target.Target +import io.github.karino2.kotlitex.view.DrawableSurface +import io.github.karino2.kotlitex.view.MathExpressionSpan +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.oppia.android.util.logging.ConsoleLogger +import org.oppia.android.util.logging.ConsoleLoggerInjectorProvider +import org.oppia.android.util.threading.DispatcherInjectorProvider +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * [ModelLoader] for rendering and caching bitmap representations of LaTeX represented by + * [MathModel]s. + * + * This loader provides support for loading a bitmap version of rendered LaTeX that's been + * pre-rendered into a Glide-cacheable bitmap. Note that this is computationally more expensive to + * use than direct rendering since it includes steps to encode the image on-disk, but it's far more + * performant for repeated rendering of the the LaTeX (real-time LaTeX rendering is very expensive + * and blocks the main thread). + */ +class MathBitmapModelLoader private constructor( + private val applicationContext: Context +) : ModelLoader { + // Ref: https://bumptech.github.io/glide/tut/custom-modelloader.html#writing-the-modelloader. + + private val backgroundDispatcher by lazy { + val injectorProvider = applicationContext.applicationContext as DispatcherInjectorProvider + val injector = injectorProvider.getDispatcherInjector() + injector.getBackgroundDispatcher() + } + + private val blockingDispatcher by lazy { + val injectorProvider = applicationContext.applicationContext as DispatcherInjectorProvider + val injector = injectorProvider.getDispatcherInjector() + injector.getBlockingDispatcher() + } + + private val consoleLogger by lazy { + val injectorProvider = applicationContext as ConsoleLoggerInjectorProvider + val injector = injectorProvider.getConsoleLoggerInjector() + injector.getConsoleLogger() + } + + override fun buildLoadData( + model: MathModel, + width: Int, + height: Int, + options: Options + ): ModelLoader.LoadData { + return ModelLoader.LoadData( + model.toKeySignature(), + LatexModelDataFetcher( + applicationContext, + model, + width, + height, + backgroundDispatcher, + blockingDispatcher, + consoleLogger + ) + ) + } + + override fun handles(model: MathModel): Boolean = true + + private class LatexModelDataFetcher( + private val applicationContext: Context, + private val model: MathModel, + private val targetWidth: Int, + private val targetHeight: Int, + private val backgroundDispatcher: CoroutineDispatcher, + private val blockingDispatcher: CoroutineDispatcher, + private val consoleLogger: ConsoleLogger + ) : DataFetcher { + override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { + // Defer execution to the app's dispatchers since synchronization is needed (and more + // performant and easier to achieve with coroutines). + CoroutineScope(backgroundDispatcher).launch { + // KotliTeX drawable initialization loads shared static state that's susceptible to race + // conditions. This synchronizes span creation so that the race condition can't happen, + // though it will likely slow down LaTeX loading a bit. Fortunately, rendering & PNG + // creation can still happen in parallel, and those are the more expensive steps. + val span = withContext(CoroutineScope(blockingDispatcher).coroutineContext) { + MathExpressionSpan( + model.rawLatex, model.lineHeight, applicationContext.assets, !model.useInlineRendering + ).also { it.ensureDrawable() } + } + val renderableText = SpannableStringBuilder("\uFFFC").apply { + setSpan(span, /* start= */ 0, /* end= */ 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + // Use Android's StaticLayout to ensure the text is rendered correctly. Note that the + // constants are derived from TextView's defaults (except width which is defaulted to 0 + // since the width isn't necessarily known ahead of time). + // Any TextPaint can be used since the span will use its own. + val textPaint = TextPaint() + @Suppress("DEPRECATION") // This call is necessary for the supported min API version. + val staticTextLayout = + StaticLayout( + renderableText, + textPaint, + /* width= */ 0, + Layout.Alignment.ALIGN_NORMAL, + /* spacingmult= */ 1f, + /* spacingadd= */ 0f, + /* includepad= */ true + ) + + // Estimate the surface necessary for rendering the LaTeX, then compute a tightly-packed + // bitmap containing rendered pixels. See drawText in BoundsCalculatingSurface and + // renderAutoSizingBitmap for more details. + val surface = BoundsCalculatingSurface() + val totalBounds = surface.also { + // The x and y are mostly unused by the draw routine. + span.draw(it, renderableText, x = 0f, y = 0, textPaint) + }.computeTotalBounds() + val boundsWidth = totalBounds.width().roundToInt() + val boundsHeight = totalBounds.height().roundToInt() + val canvasBitmap = + renderToAutoSizingBitmap(estimatedWidth = boundsWidth, estimatedHeight = boundsHeight) { + staticTextLayout.draw(it) + } + + val finalWidth = + if (targetWidth == Target.SIZE_ORIGINAL) canvasBitmap.width else targetWidth + val finalHeight = + if (targetHeight == Target.SIZE_ORIGINAL) canvasBitmap.height else targetHeight + + // Compute the final bitmap (which might need to be scaled depending on options). Note that + // any actual scaling here is likely to distort the image since it can be automatically + // cropped to minimize excess whitespace during rendering. + val bitmap = if (canvasBitmap.width != finalWidth || canvasBitmap.height != finalHeight) { + // Glide is requesting the image in a different size, so adjust it. + Bitmap.createScaledBitmap(canvasBitmap, finalWidth, finalHeight, /* filter= */ true) + } else canvasBitmap // Otherwise, the original bitmap is the correct size. + + // Convert the bitmap to a PNG to store within Glide's cache for later retrieval. + val rawBitmap = ByteArrayOutputStream().also { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, /* quality= */ 100, outputStream) + }.toByteArray() + callback.onDataReady(ByteBuffer.wrap(rawBitmap)) + }.invokeOnCompletion { + if (it != null) { + consoleLogger.e("ImageLoading", "Failed to convert LaTeX to SVG (model: $model)", it) + } + } + } + + override fun cleanup() {} + + override fun cancel() {} + + override fun getDataClass(): Class = ByteBuffer::class.java + + // 'Retrieval' is expensive in this case since a rendering operation is needed. + override fun getDataSource(): DataSource = DataSource.REMOTE + + /** + * A [DrawableSurface] which tracks the bounds necessary to draw each constituent part of LaTeX + * (rendered by KotliTeX) in order to estimate the bounds necessary to render specific LaTeX. + */ + private class BoundsCalculatingSurface : DrawableSurface { + private val initialClipRect = + RectF(-Float.MAX_VALUE, -Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE) + private var currentClip = initialClipRect + private val pastClips = mutableListOf() + private val currentBounds = RectF() + + /** + * Returns the [RectF] encompassing the estimated space required to render the entirety of all + * previous render operations called to this surface. + * + * Note that the returned [RectF] is a copy so changes to it will not change this class's + * internal state. Note also that the returned [RectF] is always starting at (0, 0) so its + * right and bottom values represent the space's width and height, respectively. + */ + fun computeTotalBounds(): RectF = RectF(currentBounds).apply { offsetTo(0f, 0f) } + + override fun clipRect(rect: RectF) { + currentClip = currentClip.intersection(rect) + } + + override fun drawLine(x0: Float, y0: Float, x1: Float, y1: Float, paint: Paint) { + currentBounds.ensureIncludes(x0, y0) + currentBounds.ensureIncludes(x1, y1) + } + + override fun drawPath(path: Path, paint: Paint) { + val pathBounds = RectF().also { path.computeBounds(it, /* unusedExact= */ true) } + currentBounds.union(pathBounds.intersection(currentClip)) + } + + override fun drawRect(rect: RectF, paint: Paint) { + currentBounds.union(rect.intersection(currentClip)) + } + + override fun drawText(text: String, x: Float, y: Float, paint: Paint) { + /* + * Text is particularly difficult to track size for since it's not obvious to actually get + * the dimensions and position of the space that the actual rendered pixels will occupy. + * https://stackoverflow.com/a/27631737/3689782 provides context both on how text is laid + * out, and provides examples of glyphs that can exceed the expected size of a line. + * + * This problem is exacerbated by KotliTeX manually positioning glyphs both horizontally and + * vertically rather than relying on built-in font kerning, tracking, and other rules (for + * a high-level reference on these, see: https://proandroiddev.com/5f06722dd611). + * + * One way to measure text is by using the Paint object (see + * https://stackoverflow.com/a/18260682/3689782), but this doesn't account for the extra + * vertical or horizontal space needed for a specific glyph. + * + * The chosen solution is to approximate vertical alignment by appending a tall character + * (such as a parenthesis) on a line below the glyph, then to compute the bounds of the + * first line and treat this as the size of the glyph. The use of StaticLayout came as a + * suggestion from https://stackoverflow.com/a/7643312/3689782 and + * https://stackoverflow.com/a/42091739/3689782. While this still is generally an + * under-approximation, it's close to the necessary space and pairs well with rendering to a + * larger canvas that can be cropped down. + */ + @Suppress("DEPRECATION") // This call is necessary for the supported min API version. + val staticLayout = + StaticLayout( + "$text\n(", + paint as TextPaint, + /* width= */ 0, + Layout.Alignment.ALIGN_NORMAL, + /* spacingmult= */ 1f, + /* spacingadd= */ 0f, + /* includepad= */ true + ) + val textBounds = staticLayout.getLineBounds().apply { offsetTo(x, y) } + currentBounds.union(textBounds.intersection(currentClip)) + } + + override fun restore() { + currentClip = pastClips.removeLast() + } + + override fun save() { + pastClips += currentClip + } + } + + private companion object { + /** + * Returns a new [Bitmap] with the contents produced by [render]. + * + * This function is useful for cases when the exact dimension requirement of results from + * [render] may not be known, but a close approximation can be computed. + * + * The size of the bitmap is initialized based on heuristic initial width/heights (defined by + * [estimatedWidth] and [estimatedHeight]). Note that it's possible the rendered contents + * exceed the size of the bitmap in which case they will be cut off. Otherwise, the returned + * bitmap will be the smallest bitmap possible to hold the results [render] in a bitmap up to + * 2x the initial specified dimensions. + * + * This method requires creating 2 [Bitmap]s at once, so it may utilize quite a bit of memory. + */ + private fun renderToAutoSizingBitmap( + estimatedWidth: Int, + estimatedHeight: Int, + render: (Canvas) -> Unit + ): Bitmap { + val fullWidth = estimatedWidth * 2 + val fullHeight = estimatedHeight * 2 + val drawX = (fullWidth.toFloat() / 2) - (estimatedWidth.toFloat() / 2) + val drawY = (fullHeight.toFloat() / 2) - (estimatedHeight.toFloat() / 2) + val fullRender = Bitmap.createBitmap(fullWidth, fullHeight, ARGB_8888).also { bitmap -> + Canvas(bitmap).also { canvas -> + canvas.save() + // Move initial drawing such that there's a width/2 and height/2 boundary around the + // entire drawing space for rendering that may overflow. + canvas.translate(drawX, drawY) + render(canvas) + canvas.restore() + } + } + + // Initialize with the largest possible "empty" (inverted) rectangle so that *any* pixel + // will become the entire initial rectangular region. + val filledRegion = + RectF(Float.MAX_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE) + for (x in 0 until fullRender.width) { + for (y in 0 until fullRender.height) { + val pixel = fullRender.getPixel(x, y) + if ((pixel.toLong() and 0xff000000L) != 0L) { + // Any not-fully transparent pixels are considered "filled in" parts of the render. + filledRegion.ensureIncludes(x.toFloat(), y.toFloat()) + } + } + } + + return if (!filledRegion.isEmpty) { + // At least some pixels have been filled. + val neededWidth = filledRegion.width().roundToInt() + val neededHeight = filledRegion.height().roundToInt() + if (neededWidth != fullWidth || neededHeight != fullHeight) { + // Less space is needed than the original bitmap which means it can be cropped to save + // on space & memory. + Bitmap.createBitmap( + fullRender, + filledRegion.left.toInt(), + filledRegion.top.toInt(), + neededWidth, + neededHeight + ) + } else fullRender // Otherwise, just return the original (since the full space is needed). + } else { + // The entire render is empty so default to a 1x1 bitmap to conserve memory. + Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, ARGB_8888) + } + } + + private fun RectF.getActualLeft(): Float = min(left, right) + private fun RectF.getActualRight(): Float = max(left, right) + private fun RectF.getActualTop(): Float = min(top, bottom) + private fun RectF.getActualBottom(): Float = max(top, bottom) + + private fun RectF.intersection(other: RectF): RectF { + // https://stackoverflow.com/a/19754915/3689782 provided a simple approach. + val intersectedLeft = max(getActualLeft(), other.getActualLeft()) + val intersectedTop = max(getActualTop(), other.getActualTop()) + val intersectedRight = min(getActualRight(), other.getActualRight()) + val intersectedBottom = min(getActualBottom(), other.getActualBottom()) + + // Make sure that rectangles which don't at least partially overlap result in a degenerate + // rectangle rather than a negative one (which would actually represent the union along + // whichever axis doesn't overlap). + val (actualLeft, actualRight) = if (intersectedRight < intersectedLeft) { + 0f to 0f + } else intersectedLeft to intersectedRight + val (actualTop, actualBottom) = if (intersectedBottom < intersectedTop) { + 0f to 0f + } else intersectedTop to intersectedBottom + return RectF(actualLeft, actualTop, actualRight, actualBottom) + } + + private fun RectF.ensureIncludes(x: Float, y: Float) { + // Note the '+1' here is necessary since 'right' and 'bottom' are exclusive bounds in the + // rectangle class (in order for the 'width' and 'height' computations to operate + // correctly). + left = min(left, x) + right = max(right, x + 1) + top = min(top, y) + bottom = max(bottom, y + 1) + } + + private fun StaticLayout.getLineBounds(line: Int = 0): RectF { + return RectF( + getLineLeft(line), + getLineTop(line).toFloat(), + getLineRight(line), + getLineBottom(line).toFloat() + ) + } + } + } + + /** [ModelLoaderFactory] for creating new [MathBitmapModelLoader]s. */ + class Factory( + private val applicationContext: Context + ) : ModelLoaderFactory { + override fun build(factory: MultiModelLoaderFactory): ModelLoader { + return MathBitmapModelLoader(applicationContext) + } + + override fun teardown() {} + } +} diff --git a/utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt b/utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt new file mode 100644 index 00000000000..77b0d03998b --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/parser/math/MathModel.kt @@ -0,0 +1,63 @@ +package org.oppia.android.util.parser.math + +import com.bumptech.glide.load.Key +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** + * Represents a set of LaTeX that can be rendered as a single bitmap. + * + * @property rawLatex the LaTeX to render + * @property lineHeight the height (in pixels) of a text line (to help scale the LaTeX) + * @property useInlineRendering whether the LaTeX will be inlined with text + */ +data class MathModel( + val rawLatex: String, + val lineHeight: Float, + val useInlineRendering: Boolean +) { + /** Returns a Glide [Key] signature (see [MathModelSignature] for specifics). */ + fun toKeySignature(): MathModelSignature = + MathModelSignature.createSignature(rawLatex, lineHeight, useInlineRendering) + + /** + * Glide [Key] that provides caching support by allowing individual renderable math scenarios to + * be comparable based on select parameters. + * + * @property rawLatex the raw LaTeX string used to render a cached bitmap + * @property lineHeightHundredX an [Int] representation of the 100x scaled line height from + * [MathModel] (this is used to preserve up to 2 digits of the height, but any past that will + * be truncated to reduce cache size for highly reusable cached renders) + * @property useInlineRendering whether the render is formatted to be displayed in-line with text + */ + data class MathModelSignature( + val rawLatex: String, + val lineHeightHundredX: Int, + val useInlineRendering: Boolean + ) : Key { + // Impl reference: http://bumptech.github.io/glide/doc/caching.html#custom-cache-invalidation. + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + val rawLatexBytes = rawLatex.encodeToByteArray() + messageDigest.update( + ByteBuffer.allocate(rawLatexBytes.size + Int.SIZE_BYTES + 1).apply { + put(rawLatexBytes) + putInt(lineHeightHundredX) + put(if (useInlineRendering) 1 else 0) + }.array() + ) + } + + internal companion object { + /** Returns a new [MathModelSignature] for the specified [MathModel] properties. */ + internal fun createSignature( + rawLatex: String, + lineHeight: Float, + useInlineRendering: Boolean + ): MathModelSignature { + val lineHeightHundredX = (lineHeight * 100f).toInt() + return MathModelSignature(rawLatex, lineHeightHundredX, useInlineRendering) + } + } + } +} diff --git a/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt b/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt index 1ecb1f717bd..38d66b30d27 100644 --- a/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt +++ b/utility/src/main/java/org/oppia/android/util/platformparameter/PlatformParameterConstants.kt @@ -80,3 +80,15 @@ const val LEARNER_STUDY_ANALYTICS = "learner_study_analytics" * and working of learner study related analytics logging. */ const val LEARNER_STUDY_ANALYTICS_DEFAULT_VALUE = false + +/** + * Qualifier for the platform parameter that controls whether to cache LaTeX rendering using Glide. + */ +@Qualifier +annotation class CacheLatexRendering + +/** Name of the platform that controls whether to cache LaTeX rendering using Glide. */ +const val CACHE_LATEX_RENDERING = "cache_latex_rendering" + +/** Default value for whether to cache LaTeX rendering using Glide. */ +const val CACHE_LATEX_RENDERING_DEFAULT_VALUE = true diff --git a/utility/src/main/java/org/oppia/android/util/threading/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/threading/BUILD.bazel index 5fb72794b0f..bb5b44721bc 100644 --- a/utility/src/main/java/org/oppia/android/util/threading/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/threading/BUILD.bazel @@ -17,6 +17,33 @@ kt_android_library( ], ) +kt_android_library( + name = "dispatcher_injector", + srcs = [ + "DispatcherInjector.kt", + ], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + ":annotations", + "//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-core", + ], +) + +kt_android_library( + name = "dispatcher_injector_provider", + srcs = [ + "DispatcherInjectorProvider.kt", + ], + visibility = [ + "//:oppia_api_visibility", + ], + deps = [ + ":dispatcher_injector", + ], +) + kt_android_library( name = "prod_module", srcs = [ diff --git a/utility/src/main/java/org/oppia/android/util/threading/DispatcherInjector.kt b/utility/src/main/java/org/oppia/android/util/threading/DispatcherInjector.kt new file mode 100644 index 00000000000..19a94f90615 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/threading/DispatcherInjector.kt @@ -0,0 +1,12 @@ +package org.oppia.android.util.threading + +import kotlinx.coroutines.CoroutineDispatcher + +/** Injector for [CoroutineDispatcher]. Implemented by a generated Dagger application component. */ +interface DispatcherInjector { + /** Returns the [BackgroundDispatcher] [CoroutineDispatcher]. */ + @BackgroundDispatcher fun getBackgroundDispatcher(): CoroutineDispatcher + + /** Returns the [BlockingDispatcher] [CoroutineDispatcher]. */ + @BlockingDispatcher fun getBlockingDispatcher(): CoroutineDispatcher +} diff --git a/utility/src/main/java/org/oppia/android/util/threading/DispatcherInjectorProvider.kt b/utility/src/main/java/org/oppia/android/util/threading/DispatcherInjectorProvider.kt new file mode 100644 index 00000000000..ee43f9b74f6 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/threading/DispatcherInjectorProvider.kt @@ -0,0 +1,7 @@ +package org.oppia.android.util.threading + +/** Provider for [DispatcherInjector]s. To be implemented by the application class. */ +interface DispatcherInjectorProvider { + /** Returns the [DispatcherInjector] corresponding to the current application context. */ + fun getDispatcherInjector(): DispatcherInjector +} diff --git a/utility/src/test/java/org/oppia/android/util/parser/html/MathTagHandlerTest.kt b/utility/src/test/java/org/oppia/android/util/parser/html/MathTagHandlerTest.kt index 5e584f9449f..cb112925660 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/html/MathTagHandlerTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/html/MathTagHandlerTest.kt @@ -12,6 +12,7 @@ import dagger.Binds import dagger.BindsInstance import dagger.Component import dagger.Module +import io.github.karino2.kotlitex.view.MathExpressionSpan import org.junit.Before import org.junit.Rule import org.junit.Test @@ -21,6 +22,7 @@ import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.times import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.junit.MockitoJUnit import org.mockito.junit.MockitoRule import org.oppia.android.testing.mockito.capture @@ -56,9 +58,21 @@ private const val MATH_WITHOUT_RAW_LATEX_MARKUP = private const val MATH_WITHOUT_FILENAME_MARKUP = "" + ":&quot;\\\\frac{2}{5}&quot;}\">" + +private const val MATH_WITHOUT_FILENAME_RENDER_TYPE_INLINE_MARKUP = + "" + +private const val MATH_WITHOUT_FILENAME_RENDER_TYPE_BLOCK_MARKUP = + "" /** Tests for [MathTagHandler]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) class MathTagHandlerTest { @@ -69,19 +83,24 @@ class MathTagHandlerTest { @Mock lateinit var mockImageRetriever: FakeImageRetriever @Captor lateinit var stringCaptor: ArgumentCaptor @Captor lateinit var retrieverTypeCaptor: ArgumentCaptor + @Captor lateinit var floatCaptor: ArgumentCaptor @Inject lateinit var context: Context @Inject lateinit var consoleLogger: ConsoleLogger private lateinit var noTagHandlers: Map - private lateinit var tagHandlersWithMathSupport: Map + private lateinit var tagHandlersWithCachedMathSupport: Map + private lateinit var tagHandlersWithUncachedMathSupport: Map @Before fun setUp() { setUpTestApplicationComponent() noTagHandlers = mapOf() - tagHandlersWithMathSupport = mapOf( - CUSTOM_MATH_TAG to MathTagHandler(consoleLogger) + tagHandlersWithCachedMathSupport = mapOf( + CUSTOM_MATH_TAG to createMathTagHandler(cacheLatexRendering = true) + ) + tagHandlersWithUncachedMathSupport = mapOf( + CUSTOM_MATH_TAG to createMathTagHandler(cacheLatexRendering = false) ) } @@ -93,7 +112,7 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = "", imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) @@ -106,9 +125,23 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = MATH_MARKUP_1, imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport + ) + + val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) + assertThat(imageSpans).hasLength(1) + } + + @Test + fun testParseHtml_withMathMarkup_missingRawLatex_includesImageSpan() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = MATH_WITHOUT_RAW_LATEX_MARKUP, + imageRetriever = mockImageRetriever, + customTagHandlers = tagHandlersWithCachedMathSupport ) + // There is an image span since the filename is still present. val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) assertThat(imageSpans).hasLength(1) } @@ -119,7 +152,7 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = MATH_MARKUP_1, imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) // The image only adds a control character, so there aren't any human-readable characters. @@ -134,7 +167,7 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = MATH_WITHOUT_CONTENT_VALUE_MARKUP, imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) @@ -142,29 +175,75 @@ class MathTagHandlerTest { } @Test - fun testParseHtml_withMathMarkup_missingRawLatex_doesNotIncludeImageSpan() { + fun testParseHtml_withMathMarkup_missingFilename_includesCachedInlineLatexImageSpan() { val parsedHtml = CustomHtmlContentHandler.fromHtml( - html = MATH_WITHOUT_RAW_LATEX_MARKUP, + html = MATH_WITHOUT_FILENAME_MARKUP, imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) + // The image span is a cached bitmap loaded from LaTeX. val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) - assertThat(imageSpans).isEmpty() + assertThat(imageSpans).hasLength(1) + verify(mockImageRetriever).loadMathDrawable( + capture(stringCaptor), capture(floatCaptor), capture(retrieverTypeCaptor) + ) + assertThat(stringCaptor.value).isEqualTo("\\frac{2}{5}") + assertThat(retrieverTypeCaptor.value).isEqualTo(ImageRetriever.Type.INLINE_TEXT_IMAGE) } @Test - fun testParseHtml_withMathMarkup_missingFilename_doesNotIncludeImageSpan() { + fun testParseHtml_withMathMarkup_missingFilename_inlineMode_includesCachedInlineLatexImageSpan() { val parsedHtml = CustomHtmlContentHandler.fromHtml( - html = MATH_WITHOUT_FILENAME_MARKUP, + html = MATH_WITHOUT_FILENAME_RENDER_TYPE_INLINE_MARKUP, imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) + // The image span is a cached bitmap loaded from LaTeX. val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) - assertThat(imageSpans).isEmpty() + assertThat(imageSpans).hasLength(1) + verify(mockImageRetriever).loadMathDrawable( + capture(stringCaptor), capture(floatCaptor), capture(retrieverTypeCaptor) + ) + assertThat(stringCaptor.value).isEqualTo("\\frac{2}{5}") + assertThat(retrieverTypeCaptor.value).isEqualTo(ImageRetriever.Type.INLINE_TEXT_IMAGE) + } + + @Test + fun testParseHtml_withMathMarkup_missingFilename_blockMode_includesCachedBlockLatexImageSpan() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = MATH_WITHOUT_FILENAME_RENDER_TYPE_BLOCK_MARKUP, + imageRetriever = mockImageRetriever, + customTagHandlers = tagHandlersWithCachedMathSupport + ) + + // The image span is a cached bitmap loaded from LaTeX. + val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) + assertThat(imageSpans).hasLength(1) + verify(mockImageRetriever).loadMathDrawable( + capture(stringCaptor), capture(floatCaptor), capture(retrieverTypeCaptor) + ) + assertThat(stringCaptor.value).isEqualTo("\\frac{2}{5}") + assertThat(retrieverTypeCaptor.value).isEqualTo(ImageRetriever.Type.BLOCK_IMAGE) + } + + @Test + fun testParseHtml_withMathMarkup_cachingOff_includesMathSpan() { + val parsedHtml = + CustomHtmlContentHandler.fromHtml( + html = MATH_WITHOUT_FILENAME_MARKUP, + imageRetriever = mockImageRetriever, + customTagHandlers = tagHandlersWithUncachedMathSupport + ) + + // The image span is a direct math expression since caching is off. + val imageSpans = parsedHtml.getSpansFromWholeString(MathExpressionSpan::class) + assertThat(imageSpans).hasLength(1) + verifyNoMoreInteractions(mockImageRetriever) // No cached image loading. } @Test @@ -186,7 +265,7 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = "$MATH_MARKUP_1 and $MATH_MARKUP_2", imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class) @@ -198,7 +277,7 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = MATH_MARKUP_1, imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) verify(mockImageRetriever).loadDrawable(capture(stringCaptor), capture(retrieverTypeCaptor)) @@ -211,7 +290,7 @@ class MathTagHandlerTest { CustomHtmlContentHandler.fromHtml( html = "$MATH_MARKUP_2 and $MATH_MARKUP_1", imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithMathSupport + customTagHandlers = tagHandlersWithCachedMathSupport ) // Verify that both images are loaded in order. @@ -225,6 +304,11 @@ class MathTagHandlerTest { .inOrder() } + private fun createMathTagHandler(cacheLatexRendering: Boolean): MathTagHandler { + // Pick an arbitrary line height since rendering doesn't actually happen in tests. + return MathTagHandler(consoleLogger, context.assets, lineHeight = 10.0f, cacheLatexRendering) + } + private fun Spannable.getSpansFromWholeString(spanClass: KClass): Array = getSpans(/* start= */ 0, /* end= */ length, spanClass.javaObjectType) diff --git a/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt b/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt index 28b61d3a885..8d78cc635d3 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt @@ -24,7 +24,8 @@ import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.LoggerModule -import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever +import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever.Type.BLOCK_IMAGE +import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever.Type.INLINE_TEXT_IMAGE import org.robolectric.annotation.LooperMode import javax.inject.Inject import javax.inject.Singleton @@ -80,7 +81,7 @@ class UrlImageParserTest { @Test fun testLoadDrawable_bitmap_blockType_loadsBitmapImage() { - urlImageParser.loadDrawable("test_image.png", ImageRetriever.Type.BLOCK_IMAGE) + urlImageParser.loadDrawable("test_image.png", BLOCK_IMAGE) val loadedBitmaps = testGlideImageLoader.getLoadedBitmaps() assertThat(loadedBitmaps).hasSize(1) @@ -89,7 +90,7 @@ class UrlImageParserTest { @Test fun testLoadDrawable_bitmap_inlineType_loadsBitmapImage() { - urlImageParser.loadDrawable("test_image.png", ImageRetriever.Type.INLINE_TEXT_IMAGE) + urlImageParser.loadDrawable("test_image.png", INLINE_TEXT_IMAGE) // The request to load the bitmap inline is ignored since inline bitmaps aren't supported. The // bitmap is instead loaded in block format. @@ -101,7 +102,7 @@ class UrlImageParserTest { @Test fun testLoadDrawable_svg_blockType_loadsSvgBlockImage() { - urlImageParser.loadDrawable("test_image.svg", ImageRetriever.Type.BLOCK_IMAGE) + urlImageParser.loadDrawable("test_image.svg", BLOCK_IMAGE) val loadedBitmaps = testGlideImageLoader.getLoadedBlockSvgs() assertThat(loadedBitmaps).hasSize(1) @@ -110,7 +111,7 @@ class UrlImageParserTest { @Test fun testLoadDrawable_svg_inlineType_loadsSvgTextImage() { - urlImageParser.loadDrawable("test_image.svg", ImageRetriever.Type.INLINE_TEXT_IMAGE) + urlImageParser.loadDrawable("test_image.svg", INLINE_TEXT_IMAGE) // The request to load the bitmap inline is ignored since inline bitmaps aren't supported. val loadedBitmaps = testGlideImageLoader.getLoadedTextSvgs() @@ -118,6 +119,51 @@ class UrlImageParserTest { assertThat(loadedBitmaps.first()).contains("test_image.svg") } + @Test + fun testLoadDrawable_latex_inlineType_loadsInlineLatexImage() { + urlImageParser.loadMathDrawable( + rawLatex = "\\frac{2}{6}", lineHeight = 20f, type = INLINE_TEXT_IMAGE + ) + + val mathDrawables = testGlideImageLoader.getLoadedMathDrawables() + assertThat(mathDrawables).hasSize(1) + assertThat(mathDrawables.first().rawLatex).isEqualTo("\\frac{2}{6}") + assertThat(mathDrawables.first().lineHeight).isWithin(1e-5f).of(20f) + assertThat(mathDrawables.first().useInlineRendering).isTrue() + } + + @Test + fun testLoadDrawable_latex_blockType_loadsBlockLatexImage() { + urlImageParser.loadMathDrawable( + rawLatex = "\\frac{2}{6}", lineHeight = 20f, type = BLOCK_IMAGE + ) + + val mathDrawables = testGlideImageLoader.getLoadedMathDrawables() + assertThat(mathDrawables).hasSize(1) + assertThat(mathDrawables.first().rawLatex).isEqualTo("\\frac{2}{6}") + assertThat(mathDrawables.first().lineHeight).isWithin(1e-5f).of(20f) + assertThat(mathDrawables.first().useInlineRendering).isFalse() + } + + @Test + fun testLoadDrawable_latex_multiple_loadsEachLatexImage() { + urlImageParser.loadMathDrawable( + rawLatex = "\\frac{1}{6}", lineHeight = 20f, type = INLINE_TEXT_IMAGE + ) + urlImageParser.loadMathDrawable( + rawLatex = "\\frac{2}{6}", lineHeight = 20f, type = INLINE_TEXT_IMAGE + ) + urlImageParser.loadMathDrawable( + rawLatex = "\\frac{2}{6}", lineHeight = 19f, type = INLINE_TEXT_IMAGE + ) + urlImageParser.loadMathDrawable( + rawLatex = "\\frac{2}{6}", lineHeight = 20f, type = BLOCK_IMAGE + ) + + val mathDrawables = testGlideImageLoader.getLoadedMathDrawables() + assertThat(mathDrawables).hasSize(4) + } + private fun setUpTestApplicationComponent() { DaggerUrlImageParserTest_TestApplicationComponent.builder() .setApplication(ApplicationProvider.getApplicationContext()) diff --git a/utility/src/test/java/org/oppia/android/util/parser/math/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/parser/math/BUILD.bazel new file mode 100644 index 00000000000..3fb1fc751be --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/parser/math/BUILD.bazel @@ -0,0 +1,19 @@ +""" +Tests for the components used to render LaTeX math expressions. +""" + +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "MathModelTest", + srcs = ["MathModelTest.kt"], + custom_package = "org.oppia.android.util.parser.math", + test_class = "org.oppia.android.util.parser.math.MathModelTest", + test_manifest = "//utility:test_manifest", + deps = [ + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/parser/math:math_latex_model", + ], +) diff --git a/utility/src/test/java/org/oppia/android/util/parser/math/MathModelTest.kt b/utility/src/test/java/org/oppia/android/util/parser/math/MathModelTest.kt new file mode 100644 index 00000000000..ceea0fbf2ca --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/parser/math/MathModelTest.kt @@ -0,0 +1,126 @@ +package org.oppia.android.util.parser.math + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.security.MessageDigest + +/** Tests for [MathModel]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +class MathModelTest { + @Test + fun testToKeySignature_sameModelByValues_returnsSameKeyWithSameDigest() { + val model1 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val model2 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val digest1 = MessageDigest.getInstance("SHA-256") + val digest2 = MessageDigest.getInstance("SHA-256") + + val key1 = model1.toKeySignature() + val key2 = model2.toKeySignature() + key1.updateDiskCacheKey(digest1) + key2.updateDiskCacheKey(digest2) + + assertThat(key1).isEqualTo(key2) + assertThat(key1.hashCode()).isEqualTo(key2.hashCode()) + assertThat(digest1.digest()).isEqualTo(digest2.digest()) + assertThat(model1).isEqualTo(model2) + } + + @Test + fun testToKeySignature_differentModelByLatex_returnsDifferentKeyWithDifferentDigest() { + val model1 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val model2 = MathModel(rawLatex = "\\frac{3}{6}", lineHeight = 21.5f, useInlineRendering = true) + val digest1 = MessageDigest.getInstance("SHA-256") + val digest2 = MessageDigest.getInstance("SHA-256") + + val key1 = model1.toKeySignature() + val key2 = model2.toKeySignature() + key1.updateDiskCacheKey(digest1) + key2.updateDiskCacheKey(digest2) + + // Since the LaTeX differs, nothing should match. + assertThat(key1).isNotEqualTo(key2) + assertThat(key1.hashCode()).isNotEqualTo(key2.hashCode()) + assertThat(digest1.digest()).isNotEqualTo(digest2.digest()) + assertThat(model1).isNotEqualTo(model2) + } + + @Test + fun testToKeySignature_differentModelByLineHeight_returnsDifferentKeyWithDifferentDigest() { + val model1 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val model2 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 20.5f, useInlineRendering = true) + val digest1 = MessageDigest.getInstance("SHA-256") + val digest2 = MessageDigest.getInstance("SHA-256") + + val key1 = model1.toKeySignature() + val key2 = model2.toKeySignature() + key1.updateDiskCacheKey(digest1) + key2.updateDiskCacheKey(digest2) + + // Since the line height differs, nothing should match. + assertThat(key1).isNotEqualTo(key2) + assertThat(key1.hashCode()).isNotEqualTo(key2.hashCode()) + assertThat(digest1.digest()).isNotEqualTo(digest2.digest()) + assertThat(model1).isNotEqualTo(model2) + } + + @Test + fun testToKeySignature_diffModelByLineHeight_withinTwoDecimals_returnsSameKeyWithSameDigest() { + val model1 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val model2 = + MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.501f, useInlineRendering = true) + val digest1 = MessageDigest.getInstance("SHA-256") + val digest2 = MessageDigest.getInstance("SHA-256") + + val key1 = model1.toKeySignature() + val key2 = model2.toKeySignature() + key1.updateDiskCacheKey(digest1) + key2.updateDiskCacheKey(digest2) + + // The line heights are close enough that they're considered equal for key purposes (but are + // still different models). + assertThat(key1).isEqualTo(key2) + assertThat(key1.hashCode()).isEqualTo(key2.hashCode()) + assertThat(digest1.digest()).isEqualTo(digest2.digest()) + assertThat(model1).isNotEqualTo(model2) + } + + @Test + fun testToKeySignature_diffModelByLineHeight_outsideTwoDecimals_returnsDiffKeyWithDiffDigest() { + val model1 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val model2 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.6f, useInlineRendering = true) + val digest1 = MessageDigest.getInstance("SHA-256") + val digest2 = MessageDigest.getInstance("SHA-256") + + val key1 = model1.toKeySignature() + val key2 = model2.toKeySignature() + key1.updateDiskCacheKey(digest1) + key2.updateDiskCacheKey(digest2) + + // Since the line height differs, nothing should match. + assertThat(key1).isNotEqualTo(key2) + assertThat(key1.hashCode()).isNotEqualTo(key2.hashCode()) + assertThat(digest1.digest()).isNotEqualTo(digest2.digest()) + assertThat(model1).isNotEqualTo(model2) + } + + @Test + fun testToKeySignature_differentModelByInlineRendering_returnsDifferentKeyWithDifferentDigest() { + val model1 = MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = true) + val model2 = + MathModel(rawLatex = "\\frac{2}{6}", lineHeight = 21.5f, useInlineRendering = false) + val digest1 = MessageDigest.getInstance("SHA-256") + val digest2 = MessageDigest.getInstance("SHA-256") + + val key1 = model1.toKeySignature() + val key2 = model2.toKeySignature() + key1.updateDiskCacheKey(digest1) + key2.updateDiskCacheKey(digest2) + + // Since the inline rendering setting differs, nothing should match. + assertThat(key1).isNotEqualTo(key2) + assertThat(key1.hashCode()).isNotEqualTo(key2.hashCode()) + assertThat(digest1.digest()).isNotEqualTo(digest2.digest()) + assertThat(model1).isNotEqualTo(model2) + } +}