diff --git a/app/src/sharedTest/java/org/oppia/app/parser/HtmlParserTest.kt b/app/src/sharedTest/java/org/oppia/app/parser/HtmlParserTest.kt index d594645404c..7326f30ec2a 100644 --- a/app/src/sharedTest/java/org/oppia/app/parser/HtmlParserTest.kt +++ b/app/src/sharedTest/java/org/oppia/app/parser/HtmlParserTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.Application import android.content.Context import android.content.Intent +import android.content.res.Resources import android.graphics.Bitmap import android.text.Spannable import android.widget.TextView @@ -39,6 +40,7 @@ import org.oppia.util.logging.EnableConsoleLog import org.oppia.util.logging.EnableFileLog import org.oppia.util.logging.GlobalLogLevel import org.oppia.util.logging.LogLevel +import org.oppia.util.parser.CustomBulletSpan import org.oppia.util.parser.DefaultGcsPrefix import org.oppia.util.parser.DefaultGcsResource import org.oppia.util.parser.GlideImageLoader @@ -57,8 +59,7 @@ import javax.inject.Singleton class HtmlParserTest { private lateinit var launchedActivity: Activity - @Inject - lateinit var htmlParserFactory: HtmlParser.Factory + @Inject lateinit var htmlParserFactory: HtmlParser.Factory @get:Rule var activityTestRule: ActivityTestRule = ActivityTestRule( @@ -85,7 +86,7 @@ class HtmlParserTest { fun tearDown() { Intents.release() } - + private fun setUpTestApplicationComponent() { DaggerHtmlParserTest_TestApplicationComponent.builder() .setApplication(ApplicationProvider.getApplicationContext()) @@ -128,14 +129,39 @@ class HtmlParserTest { onView(withId(R.id.test_html_content_text_view)).check(matches(not(textView.text.toString()))) } + @Test + fun testHtmlContent_customSpan_isAdded() { + val textView = activityTestRule.activity.findViewById(R.id.test_html_content_text_view) as TextView + val htmlParser = htmlParserFactory.create(/* entityType= */ "", /* entityId= */ "", /* imageCenterAlign= */ true) + val htmlResult: Spannable = htmlParser.parseOppiaHtml( + "

You should know the following before going on:

" + + "", + textView + ) + + /* Reference: https://medium.com/androiddevelopers/spantastic-text-styling-with-spans-17b0c16b4568#e345 */ + val bulletSpans = htmlResult.getSpans(0, htmlResult.length, CustomBulletSpan::class.java) + assertThat(bulletSpans.size.toLong()).isEqualTo(2) + + val bulletSpan0 = bulletSpans[0] as CustomBulletSpan + assertThat(bulletSpan0).isNotNull() + + val bulletSpan1 = bulletSpans[1] as CustomBulletSpan + assertThat(bulletSpan1).isNotNull() + } + class FakeImageLoader : ImageLoader { override fun load(imageUrl: String, target: CustomTarget) { } } - @Qualifier - annotation class TestDispatcher + private fun getResources(): Resources { + return ApplicationProvider.getApplicationContext().resources + } + + @Qualifier annotation class TestDispatcher // TODO(#89): Move this to a common test application component. @Module diff --git a/utility/src/main/java/org/oppia/util/parser/CustomBulletSpan.kt b/utility/src/main/java/org/oppia/util/parser/CustomBulletSpan.kt new file mode 100755 index 00000000000..5b4512cd69c --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/CustomBulletSpan.kt @@ -0,0 +1,77 @@ +package org.oppia.util.parser + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Path.Direction +import android.text.Layout +import android.text.Spanned +import android.text.style.LeadingMarginSpan +import org.oppia.util.R + +// TODO(#562): Add screenshot tests to check whether the drawing logic works correctly on all devices. + +/** + * Copy of [android.text.style.BulletSpan] from android SDK 28 with removed internal code. + * This class helps us to customise bullet radius, gap width and offset present in rich-text. + * Reference: https://github.com/davidbilik/bullet-span-sample + */ +class CustomBulletSpan(context: Context) : LeadingMarginSpan { + private var bulletRadius: Int = 0 + private var gapWidth: Int = 0 + private var yOffset: Int = 0 + private var bulletLeadingMargin: Int = 0 + + init { + bulletRadius = context.resources.getDimensionPixelSize(R.dimen.bullet_radius) + gapWidth = context.resources.getDimensionPixelSize(R.dimen.bullet_gap_width) + yOffset = context.resources.getDimensionPixelSize(R.dimen.bullet_y_offset) + bulletLeadingMargin = context.resources.getDimensionPixelSize(R.dimen.bullet_leading_margin) + } + + private var mBulletPath: Path? = null + + override fun getLeadingMargin(first: Boolean): Int { + return bulletLeadingMargin + } + + override fun drawLeadingMargin( + canvas: Canvas, paint: Paint, x: Int, dir: Int, + top: Int, baseline: Int, bottom: Int, + text: CharSequence, start: Int, end: Int, + first: Boolean, + layout: Layout? + ) { + if ((text as Spanned).getSpanStart(this) == start) { + val style = paint.style + paint.style = Paint.Style.FILL + + var yPosition = if (layout != null) { + val line = layout.getLineForOffset(start) + layout.getLineBaseline(line).toFloat() - bulletRadius * 2f + } else { + (top + bottom) / 2f + } + yPosition += yOffset + + val xPosition = (x + dir * bulletRadius).toFloat() + + if (canvas.isHardwareAccelerated) { + if (mBulletPath == null) { + mBulletPath = Path() + mBulletPath!!.addCircle(0.0f, 0.0f, bulletRadius.toFloat(), Direction.CW) + } + + canvas.save() + canvas.translate(xPosition, yPosition) + canvas.drawPath(mBulletPath!!, paint) + canvas.restore() + } else { + canvas.drawCircle(xPosition, yPosition, bulletRadius.toFloat(), paint) + } + + paint.style = style + } + } +} diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt index affe7096d5f..ba65ab504f9 100755 --- a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -1,8 +1,11 @@ package org.oppia.util.parser +import android.os.Build import android.text.Html import android.text.Spannable import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.BulletSpan import android.widget.TextView import javax.inject.Inject @@ -44,18 +47,28 @@ class HtmlParser private constructor( } val imageGetter = urlImageParserFactory.create(htmlContentTextView, entityType, entityId, imageCenterAlign) - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - trimSpannable( - Html.fromHtml( - htmlContent, - Html.FROM_HTML_MODE_LEGACY, - imageGetter, /* tagHandler= */ - null - ) as SpannableStringBuilder - ) + + @Suppress("DEPRECATION") + val htmlSpannable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(htmlContent, Html.FROM_HTML_MODE_LEGACY, imageGetter, LiTagHandler()) } else { - trimSpannable(Html.fromHtml(htmlContent, imageGetter, /* tagHandler= */ null) as SpannableStringBuilder) + Html.fromHtml(htmlContent, imageGetter, LiTagHandler()) + } + + val spannableBuilder = SpannableStringBuilder(htmlSpannable) + val bulletSpans = spannableBuilder.getSpans(0, spannableBuilder.length, BulletSpan::class.java) + bulletSpans.forEach { + val start = spannableBuilder.getSpanStart(it) + val end = spannableBuilder.getSpanEnd(it) + spannableBuilder.removeSpan(it) + spannableBuilder.setSpan( + CustomBulletSpan(htmlContentTextView.context), + start, + end, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) } + return trimSpannable(spannableBuilder) } private fun trimSpannable(spannable: SpannableStringBuilder): SpannableStringBuilder { diff --git a/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt b/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt new file mode 100755 index 00000000000..83d86f1aa7d --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/LiTagHandler.kt @@ -0,0 +1,37 @@ +package org.oppia.util.parser + +import android.text.Editable +import android.text.Html +import android.text.Spannable +import android.text.Spanned +import android.text.style.BulletSpan +import org.xml.sax.XMLReader + +/** + * [Html.TagHandler] implementation that processes
  • tags and creates bullets. + * + * Reference: https://github.com/davidbilik/bullet-span-sample + */ +class LiTagHandler : Html.TagHandler { + /** + * Helper marker class. Based on [Html.fromHtml] implementation. + */ + class Bullet + + override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { + if (tag == "li" && opening) { + output.setSpan(Bullet(), output.length, output.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + } + if (tag == "li" && !opening) { + output.append("
    ") + val lastMark = output.getSpans(0, output.length, Bullet::class.java).lastOrNull() + lastMark?.let { + val start = output.getSpanStart(it) + output.removeSpan(it) + if (start != output.length) { + output.setSpan(BulletSpan(), start, output.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + } + } + } +} diff --git a/utility/src/main/res/values/dimens.xml b/utility/src/main/res/values/dimens.xml new file mode 100644 index 00000000000..021b9b756e3 --- /dev/null +++ b/utility/src/main/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + 4dp + 16dp + 2dp + 24dp +