diff --git a/app/src/main/res/layout-land/resume_lesson_fragment.xml b/app/src/main/res/layout-land/resume_lesson_fragment.xml
index e317a2eff27..ab5f816d7d2 100644
--- a/app/src/main/res/layout-land/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout-land/resume_lesson_fragment.xml
@@ -94,7 +94,7 @@
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@drawable/state_button_primary_background"
- android:drawableTint="@color/component_color_shared_white_background_color"
+ app:drawableTint="@color/component_color_shared_white_background_color"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:minWidth="144dp"
diff --git a/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml b/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
index 5997f1069a4..6d803c68388 100644
--- a/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
+++ b/app/src/main/res/layout-sw600dp-land/resume_lesson_fragment.xml
@@ -124,7 +124,7 @@
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@drawable/state_button_primary_background"
- android:drawableTint="@color/component_color_shared_white_background_color"
+ app:drawableTint="@color/component_color_shared_white_background_color"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:minWidth="144dp"
diff --git a/app/src/main/res/layout/bottom_sheet_options_menu_fragment.xml b/app/src/main/res/layout/bottom_sheet_options_menu_fragment.xml
index fe98ab51455..9e0c7ad8408 100644
--- a/app/src/main/res/layout/bottom_sheet_options_menu_fragment.xml
+++ b/app/src/main/res/layout/bottom_sheet_options_menu_fragment.xml
@@ -15,7 +15,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
- android:drawableTint="@color/component_color_shared_multipane_icon_color"
+ app:drawableTint="@color/component_color_shared_multipane_icon_color"
android:gravity="center_vertical"
android:minHeight="48dp"
android:text="@string/menu_options"
@@ -28,7 +28,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
- android:drawableTint="@color/component_color_shared_multipane_icon_color"
+ app:drawableTint="@color/component_color_shared_multipane_icon_color"
android:gravity="center_vertical"
android:minHeight="48dp"
android:text="@string/menu_help"
@@ -41,7 +41,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
- android:drawableTint="@color/component_color_shared_multipane_icon_color"
+ app:drawableTint="@color/component_color_shared_multipane_icon_color"
android:gravity="center_vertical"
android:minHeight="48dp"
android:text="@string/bottom_sheet_options_menu_close"
diff --git a/app/src/main/res/layout/circular_progress_indicator_adapters_test_activity.xml b/app/src/main/res/layout/circular_progress_indicator_adapters_test_activity.xml
index c0f63136ec5..4d5161fb530 100644
--- a/app/src/main/res/layout/circular_progress_indicator_adapters_test_activity.xml
+++ b/app/src/main/res/layout/circular_progress_indicator_adapters_test_activity.xml
@@ -1,6 +1,7 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -20,6 +21,7 @@
android:layout_height="wrap_content"
android:max="@{viewModel.defaultMaximum}"
android:min="@{viewModel.defaultMinimum}"
+ tools:targetApi="26"
app:animatedProgress="@{viewModel.currentAutoProgress}" />
+ android:min="@{viewModel.defaultMinimum}"
+ tools:targetApi="26"/>
diff --git a/app/src/main/res/layout/content_item.xml b/app/src/main/res/layout/content_item.xml
index 09b1eb682ce..a29a44814fe 100644
--- a/app/src/main/res/layout/content_item.xml
+++ b/app/src/main/res/layout/content_item.xml
@@ -1,5 +1,6 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -44,6 +45,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:breakStrategy="simple"
+ tools:targetApi="23"
android:fontFamily="sans-serif"
android:minWidth="48dp"
android:minHeight="48dp"
diff --git a/app/src/main/res/layout/feedback_item.xml b/app/src/main/res/layout/feedback_item.xml
index f87b4d316b0..4bc646346bc 100644
--- a/app/src/main/res/layout/feedback_item.xml
+++ b/app/src/main/res/layout/feedback_item.xml
@@ -1,5 +1,6 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -44,6 +45,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:breakStrategy="simple"
+ tools:targetApi="23"
android:fontFamily="sans-serif"
android:minWidth="48dp"
android:minHeight="48dp"
diff --git a/app/src/main/res/layout/profile_list_control_buttons.xml b/app/src/main/res/layout/profile_list_control_buttons.xml
index fa3809a5b29..ac6a92b7e05 100644
--- a/app/src/main/res/layout/profile_list_control_buttons.xml
+++ b/app/src/main/res/layout/profile_list_control_buttons.xml
@@ -1,6 +1,7 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -52,6 +53,7 @@
android:layout_height="wrap_content"
android:max="@{viewModel.forceUploadProgress.totalEventsToUpload}"
android:min="0"
+ tools:targetApi="26"
app:animatedProgress="@{viewModel.forceUploadProgress.eventsUploaded}"
android:visibility="@{viewModel.forceUploadProgress.hasEventsToUpload() ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@+id/learner_analytics_upload_logs_now_button"
diff --git a/app/src/main/res/layout/profile_progress_recently_played_story_card.xml b/app/src/main/res/layout/profile_progress_recently_played_story_card.xml
index 5730c5b283e..9c5602c9f26 100755
--- a/app/src/main/res/layout/profile_progress_recently_played_story_card.xml
+++ b/app/src/main/res/layout/profile_progress_recently_played_story_card.xml
@@ -1,6 +1,7 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -38,6 +39,7 @@
app:entityId="@{viewModel.promotedStory.storyId}"
app:entityType="@{viewModel.entityType}"
android:forceDarkAllowed="false"
+ tools:targetApi="29"
app:layout_constraintDimensionRatio="4:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/app/src/main/res/layout/promoted_story_card.xml b/app/src/main/res/layout/promoted_story_card.xml
index 91e711e0dae..84f2062cd9c 100755
--- a/app/src/main/res/layout/promoted_story_card.xml
+++ b/app/src/main/res/layout/promoted_story_card.xml
@@ -1,6 +1,7 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -36,6 +37,7 @@
app:entityId="@{viewModel.promotedStory.storyId}"
app:entityType="@{viewModel.entityType}"
android:forceDarkAllowed="false"
+ tools:targetApi="29"
app:layout_constraintDimensionRatio="H, 16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/app/src/main/res/layout/recently_played_story_card.xml b/app/src/main/res/layout/recently_played_story_card.xml
index 0994de8f994..d177628db8f 100755
--- a/app/src/main/res/layout/recently_played_story_card.xml
+++ b/app/src/main/res/layout/recently_played_story_card.xml
@@ -1,6 +1,7 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
@@ -39,6 +40,7 @@
app:entityId="@{viewModel.promotedStory.storyId}"
app:entityType="@{viewModel.entityType}"
android:forceDarkAllowed="false"
+ tools:targetApi="29"
app:layout_constraintDimensionRatio="4:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/domain/src/main/assets/test_exp_id_2.json b/domain/src/main/assets/test_exp_id_2.json
index 494f1937677..69c992ad580 100644
--- a/domain/src/main/assets/test_exp_id_2.json
+++ b/domain/src/main/assets/test_exp_id_2.json
@@ -496,7 +496,14 @@
"denominator": 2
}
}
- }],
+ },
+ {
+ "rule_type": "HasIntegerPartEqualTo",
+ "inputs": {
+ "x": 1
+ }
+ }
+ ],
"outcome": {
"dest": "MultipleChoice",
"feedback": {
diff --git a/domain/src/main/assets/test_exp_id_2.textproto b/domain/src/main/assets/test_exp_id_2.textproto
index 1fc383fbf27..2917343097e 100644
--- a/domain/src/main/assets/test_exp_id_2.textproto
+++ b/domain/src/main/assets/test_exp_id_2.textproto
@@ -806,6 +806,15 @@ states {
}
rule_type: "IsExactlyEqualTo"
}
+ rule_specs {
+ input {
+ key: "x"
+ value {
+ signed_int: 1
+ }
+ }
+ rule_type: "HasIntegerPartEqualTo"
+ }
}
solution {
answer_is_exclusive: true
diff --git a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt
index 227079a0640..a17bf9301e4 100644
--- a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt
+++ b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt
@@ -318,6 +318,10 @@ class StateRetriever @Inject constructor() {
InteractionObject.newBuilder()
.setNonNegativeInt(inputJson.getInt(keyName))
.build()
+ "HasIntegerPartEqualTo" ->
+ InteractionObject.newBuilder()
+ .setSignedInt(inputJson.getInt(keyName))
+ .build()
else ->
InteractionObject.newBuilder()
.setFraction(parseFraction(inputJson.getJSONObject(keyName)))
diff --git a/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt b/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt
index b0a2d882258..4a4f2adc574 100644
--- a/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt
+++ b/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt
@@ -630,6 +630,32 @@ class StateRetrieverTest {
assertThat(state.linkedSkillId).isEqualTo("test_skill_id_2")
}
+ @Test
+ fun testParseState_withFractionInputInteraction_parsesRuleHasIntegerPartEqualToRuleSpec() {
+ val state = loadStateFromJson(
+ stateName = "Fractions",
+ explorationName = TEST_EXPLORATION_ID_2
+ )
+
+ val ruleSpecMap = state.interaction.answerGroupsList
+ .flatMap(AnswerGroup::getRuleSpecsList)
+ .associateBy(RuleSpec::getRuleType)
+ assertThat(ruleSpecMap).containsKey("HasIntegerPartEqualTo")
+ }
+
+ @Test
+ fun testParseState_withFractionInput_parsesRuleHasIntegerPartEqualToValueAtX() {
+ val state = loadStateFromJson(
+ stateName = "Fractions",
+ explorationName = TEST_EXPLORATION_ID_2
+ )
+
+ val ruleSpecMap = lookUpRuleSpec(state, "HasIntegerPartEqualTo")
+ val expectedInputInteractionObject =
+ InteractionObject.newBuilder().setSignedInt(1).build()
+ assertThat(ruleSpecMap.inputMap["x"]).isEqualTo(expectedInputInteractionObject)
+ }
+
/**
* Return the first [RuleSpec] in the specified [State] matching the specified rule type, or fails
* if one cannot be found.
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 eabfa3836d7..8a4de9460c1 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
@@ -2,6 +2,9 @@ package org.oppia.android.util.parser.html
import android.app.Application
import android.content.res.AssetManager
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.drawable.Drawable
import android.text.Editable
import android.text.Spannable
import android.text.style.ImageSpan
@@ -60,12 +63,13 @@ class MathTagHandler(
}
is MathContent.MathAsLatex -> {
if (cacheLatexRendering) {
- ImageSpan(
+ LatexImageSpan(
imageRetriever.loadMathDrawable(
content.rawLatex,
lineHeight,
type = if (useInlineRendering) INLINE_TEXT_IMAGE else BLOCK_IMAGE
- )
+ ),
+ useInlineRendering
)
} else {
MathExpressionSpan(
@@ -144,3 +148,87 @@ class MathTagHandler(
return mathVal?.let { "Math content $it" } ?: ""
}
}
+
+/** An [ImageSpan] that vertically centers a LaTeX drawable within the surrounding text. */
+private class LatexImageSpan(
+ imageDrawable: Drawable?,
+ private val isInlineMode: Boolean
+) : ImageSpan(imageDrawable ?: createEmptyDrawable()) {
+
+ companion object {
+ private const val INLINE_VERTICAL_SHIFT_RATIO = 0.9f
+
+ private fun createEmptyDrawable(): Drawable {
+ return object : Drawable() {
+ override fun draw(canvas: Canvas) {}
+ override fun setAlpha(alpha: Int) {}
+ override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
+ override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSPARENT
+
+ init {
+ setBounds(0, 0, 1, 1)
+ }
+ }
+ }
+ }
+
+ override fun getSize(
+ paint: Paint,
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ fontMetrics: Paint.FontMetricsInt?
+ ): Int {
+ val drawableBounds = drawable.bounds
+ val imageHeight = drawableBounds.height()
+ val textMetrics = paint.fontMetricsInt
+ val textHeight = textMetrics.descent - textMetrics.ascent
+
+ fontMetrics?.let { metrics ->
+ if (isInlineMode) {
+ val verticalShift = (imageHeight - textHeight) / 2 +
+ (textMetrics.descent * INLINE_VERTICAL_SHIFT_RATIO).toInt()
+ metrics.ascent = textMetrics.ascent - verticalShift
+ metrics.top = metrics.ascent
+ metrics.descent = textMetrics.descent + verticalShift
+ metrics.bottom = metrics.descent
+ } else {
+ val totalHeight = (imageHeight * 1.2).toInt()
+ metrics.ascent = -totalHeight / 2
+ metrics.top = metrics.ascent
+ metrics.descent = totalHeight / 2
+ metrics.bottom = metrics.descent
+ }
+ }
+ return drawableBounds.right
+ }
+
+ override fun draw(
+ canvas: Canvas,
+ text: CharSequence,
+ start: Int,
+ end: Int,
+ x: Float,
+ lineTop: Int,
+ baseline: Int,
+ lineBottom: Int,
+ paint: Paint
+ ) {
+ canvas.save()
+
+ val imageHeight = drawable.bounds.height()
+ val yOffset = if (isInlineMode) {
+ val metrics = paint.fontMetricsInt
+ val ascent = metrics.ascent.toFloat()
+ val descent = metrics.descent.toFloat()
+ val expectedCenterY = baseline.toFloat() + (ascent + descent) / 2f
+ expectedCenterY - (imageHeight / 2f)
+ } else {
+ lineTop.toFloat() + (lineBottom - lineTop - imageHeight) / 2f
+ }
+
+ canvas.translate(x, yOffset)
+ drawable.draw(canvas)
+ canvas.restore()
+ }
+}
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 d2c15cf1c27..e2a2365b61d 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
@@ -2,7 +2,9 @@ package org.oppia.android.util.parser.html
import android.app.Application
import android.content.Context
+import android.graphics.Canvas
import android.graphics.Color
+import android.graphics.Paint
import android.text.Html
import android.text.Spannable
import android.text.style.ImageSpan
@@ -21,6 +23,8 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Captor
import org.mockito.Mock
+import org.mockito.Mockito.eq
+import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
@@ -39,6 +43,7 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.math.abs
import kotlin.reflect.KClass
private const val MATH_MARKUP_1 =
@@ -107,6 +112,129 @@ class MathTagHandlerTest {
}
// TODO(#3085): Introduce test for verifying that the error log scenario is logged correctly.
+ @Test
+ fun testParseHtml_withMathMarkup_cachingOn_imageSpanHasCorrectMetrics() {
+
+ val parsedHtml = CustomHtmlContentHandler.fromHtml(
+ html = MATH_WITHOUT_FILENAME_MARKUP,
+ imageRetriever = mockImageRetriever,
+ customTagHandlers = tagHandlersWithCachedMathSupport
+ )
+ val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
+ assertThat(imageSpans).hasLength(1)
+
+ val paint = Paint()
+ paint.textSize = 20f
+ val originalMetrics = Paint.FontMetricsInt()
+ paint.getFontMetricsInt(originalMetrics)
+
+ val spanMetrics = Paint.FontMetricsInt()
+ imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, spanMetrics)
+
+ // The span's center should align with the text's center
+ val originalCenter = (originalMetrics.descent + originalMetrics.ascent) / 2
+ val spanCenter = (spanMetrics.descent + spanMetrics.ascent) / 2
+ assertThat(abs(originalCenter - spanCenter)).isLessThan(2)
+ }
+
+ @Test
+ fun testParseHtml_withMathMarkup_cachingOn_drawsAtCorrectVerticalPosition() {
+
+ val parsedHtml = CustomHtmlContentHandler.fromHtml(
+ html = MATH_WITHOUT_FILENAME_MARKUP,
+ imageRetriever = mockImageRetriever,
+ customTagHandlers = tagHandlersWithCachedMathSupport
+ )
+
+ val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
+ assertThat(imageSpans).hasLength(1)
+
+ val mockCanvas = mock(Canvas::class.java)
+ val paint = Paint()
+ paint.textSize = 20f
+
+ val metrics = paint.fontMetricsInt
+ val y = 100
+
+ imageSpans[0].draw(
+ mockCanvas,
+ parsedHtml,
+ 0,
+ parsedHtml.length,
+ 0f,
+ 0,
+ y,
+ 200,
+ paint
+ )
+
+ val textHeight = (metrics.descent - metrics.ascent).toFloat()
+ val textMidline = y.toFloat() - (textHeight / 2f)
+ val verticalShift = metrics.descent * 0.9f
+ val drawable = imageSpans[0].drawable
+ val expectedTranslation = textMidline + verticalShift - (drawable.bounds.height() / 2f)
+
+ // The translation should position the drawable centered around the text baseline
+ verify(mockCanvas).save()
+ verify(mockCanvas).translate(
+ eq(0f),
+ capture(floatCaptor)
+ )
+ assertThat(floatCaptor.value).isWithin(1f).of(expectedTranslation)
+ verify(mockCanvas).restore()
+ }
+
+ @Test
+ fun testParseHtml_withMathMarkup_cachingOn_maintainsConsistentHeight() {
+
+ val parsedHtml = CustomHtmlContentHandler.fromHtml(
+ html = MATH_WITHOUT_FILENAME_MARKUP,
+ imageRetriever = mockImageRetriever,
+ customTagHandlers = tagHandlersWithCachedMathSupport
+ )
+
+ val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
+ assertThat(imageSpans).hasLength(1)
+
+ val paint = Paint()
+ paint.textSize = 20f
+
+ val metrics1 = Paint.FontMetricsInt()
+ val metrics2 = Paint.FontMetricsInt()
+
+ val size1 = imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, metrics1)
+ val size2 = imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, metrics2)
+
+ assertThat(size1).isEqualTo(size2)
+ assertThat(metrics1.ascent).isEqualTo(metrics2.ascent)
+ assertThat(metrics1.descent).isEqualTo(metrics2.descent)
+ assertThat(metrics1.top).isEqualTo(metrics2.top)
+ assertThat(metrics1.bottom).isEqualTo(metrics2.bottom)
+ }
+
+ @Test
+ fun testParseHtml_withMathMarkup_cachingOn_respectsLineHeight() {
+
+ val parsedHtml = CustomHtmlContentHandler.fromHtml(
+ html = MATH_WITHOUT_FILENAME_MARKUP,
+ imageRetriever = mockImageRetriever,
+ customTagHandlers = tagHandlersWithCachedMathSupport
+ )
+
+ val imageSpans = parsedHtml.getSpansFromWholeString(ImageSpan::class)
+ assertThat(imageSpans).hasLength(1)
+
+ val paint = Paint()
+ paint.textSize = 20f
+
+ val metrics = Paint.FontMetricsInt()
+ imageSpans[0].getSize(paint, parsedHtml, 0, parsedHtml.length, metrics)
+
+ // Verify that the total height does not exceed the line height
+ val totalHeight = metrics.bottom - metrics.top
+ val lineHeight = paint.textSize * 1.2f
+ assertThat(totalHeight.toFloat()).isLessThan(lineHeight)
+ }
@Test
fun testParseHtml_emptyString_doesNotIncludeImageSpan() {