Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #4848: Ensure Complete Content Descriptions for Custom HTML Tags in CustomHtmlContentHandler #5614

Merged
merged 25 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import org.xml.sax.Attributes

/** The custom tag corresponding to [ConceptCardTagHandler]. */
const val CUSTOM_CONCEPT_CARD_TAG = "oppia-noninteractive-skillreview"
const val CUSTOM_CONCEPT_CARD_SKILL_ID = "skill_id-with-value"
const val CUSTOM_CONCEPT_CARD_TEXT_VALUE = "text-with-value"

// https://mohammedlakkadshaw.com/blog/handling-custom-tags-in-android-using-html-taghandler.html/
class ConceptCardTagHandler(
private val listener: ConceptCardLinkClickListener,
private val consoleLogger: ConsoleLogger
) : CustomHtmlContentHandler.CustomTagHandler {
) : CustomHtmlContentHandler.CustomTagHandler, CustomHtmlContentHandler.ContentDescriptionProvider {
override fun handleTag(
attributes: Attributes,
openIndex: Int,
Expand All @@ -24,8 +26,8 @@ class ConceptCardTagHandler(
imageRetriever: CustomHtmlContentHandler.ImageRetriever?
) {
// Replace the custom tag with a clickable piece of text based on the tag's customizations.
val skillId = attributes.getJsonStringValue("skill_id-with-value")
val text = attributes.getJsonStringValue("text-with-value")
val skillId = attributes.getJsonStringValue(CUSTOM_CONCEPT_CARD_SKILL_ID)
val text = attributes.getJsonStringValue(CUSTOM_CONCEPT_CARD_TEXT_VALUE)
if (skillId != null && text != null) {
val spannableBuilder = SpannableStringBuilder(text)
spannableBuilder.setSpan(
Expand All @@ -48,4 +50,12 @@ class ConceptCardTagHandler(
*/
fun onConceptCardLinkClicked(view: View, skillId: String)
}

override fun getContentDescription(attributes: Attributes): String? {
val skillId = attributes.getJsonStringValue(CUSTOM_CONCEPT_CARD_SKILL_ID)
val text = attributes.getJsonStringValue(CUSTOM_CONCEPT_CARD_TEXT_VALUE)
return if (!skillId.isNullOrBlank() && !text.isNullOrBlank()) {
"$text concept card $skillId"
} else ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ class CustomHtmlContentHandler private constructor(
private var originalContentHandler: ContentHandler? = null
private var currentTrackedTag: TrackedTag? = null
private val currentTrackedCustomTags = ArrayDeque<TrackedCustomTag>()
private val contentDescriptionBuilder = StringBuilder()
private val tagContentDescriptions = mutableMapOf<Int, String>()
private var isInListItem = false
private var pendingNewline = false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what pending new line here means, maybe a rename or adding a comment might clear things up. I had a bit of trouble understanding how it is used in override fun characters below.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It acts as a flag that indicates whether a newline character should be inserted before the next text content. It's used when dealing with block-level HTML elements

private val blockTags = setOf("p", "ol", "ul", "li", "oppia-ul", "oppia-ol", "oppia-li", "div")

override fun endElement(uri: String?, localName: String?, qName: String?) {
originalContentHandler?.endElement(uri, localName, qName)
if (localName in blockTags) {
isInListItem = false
}
Comment on lines 35 to +38
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could have been:

val tagName = qName ?: localName  // Fallback to localName if qName is null
   if (tagName in blockTags) {
        isInListItem = false
    }

qName (qualified name) is generally more reliable unless your parser enforces namespaces.

currentTrackedTag = null
}

Expand All @@ -45,6 +53,11 @@ class CustomHtmlContentHandler private constructor(

override fun characters(ch: CharArray?, start: Int, length: Int) {
originalContentHandler?.characters(ch, start, length)
if (pendingNewline) {
contentDescriptionBuilder.append('\n')
pendingNewline = false
}
ch?.let { contentDescriptionBuilder.append(String(it, start, length)) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ch?.let { contentDescriptionBuilder.appendRange(it, start, start + length) }

}

override fun endDocument() {
Expand All @@ -56,6 +69,10 @@ class CustomHtmlContentHandler private constructor(
// Defer custom tag management to the tag handler so that Android's element parsing takes
// precedence.
currentTrackedTag = TrackedTag(checkNotNull(localName), checkNotNull(atts))
if (localName in blockTags) {
pendingNewline = true
isInListItem = true
}
originalContentHandler?.startElement(uri, localName, qName, atts)
}

Expand Down Expand Up @@ -110,6 +127,14 @@ class CustomHtmlContentHandler private constructor(
"Expected tracked tag $currentTrackedTag to match custom tag: $tag"
}
val (_, attributes, openTagIndex) = currentTrackedCustomTag

val handler = customTagHandlers.getValue(tag)
if (handler is ContentDescriptionProvider) {
val contentDesc = handler.getContentDescription(attributes)
if (contentDesc != null) {
tagContentDescriptions[openTagIndex] = contentDesc
}
}
customTagHandlers.getValue(tag).handleClosingTag(output, indentation = 0, tag)
customTagHandlers.getValue(tag)
.handleTag(attributes, openTagIndex, output.length, output, imageRetriever)
Expand All @@ -123,7 +148,26 @@ class CustomHtmlContentHandler private constructor(
val attributes: Attributes,
val openTagIndex: Int
)

/**
* Returns the complete content description for the processed HTML, including descriptions
* from all custom tags.
*/
private fun getContentDescription(): String {
val rawDesc = buildString {
var lastIndex = 0
tagContentDescriptions.entries.sortedBy { it.key }.forEach { (index, description) ->
if (index > lastIndex) {
append(contentDescriptionBuilder.substring(lastIndex, index))
}
append(description)
lastIndex = index
}
if (lastIndex < contentDescriptionBuilder.length) {
append(contentDescriptionBuilder.substring(lastIndex))
}
}
return rawDesc.replace(Regex("\n+"), "\n").trim()
}
/** Handler interface for a custom tag and its attributes. */
interface CustomTagHandler {
/**
Expand Down Expand Up @@ -166,6 +210,15 @@ class CustomHtmlContentHandler private constructor(
fun handleClosingTag(output: Editable, indentation: Int, tag: String) {}
}

/** Handler Interface for tag handlers that provide content descriptions. */
interface ContentDescriptionProvider {
/**
* Returns a content description string for this tag based on its attributes,
* or null if no description is available.
*/
fun getContentDescription(attributes: Attributes): String?
}

/**
* Retriever of images for custom tag handlers. The built-in Android analog for this class is
* Html's ImageGetter.
Expand Down Expand Up @@ -196,6 +249,24 @@ class CustomHtmlContentHandler private constructor(
}

companion object {
/**
* Returns the content description for the HTML content, processing all custom tags that implement
* [ContentDescriptionProvider].
*/
fun <T> getContentDescription(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that while this function was created, it was not used as required by the issue description. The only present usage is in HintViewModel, which means descriptions will be geneerated for Hints only. I believe that other content viewmodels need to use this too, in order to correctly generate descriptions.

According to #4848, we should ensure descriptions are generated for answers, link cases, including anchors, or any case when text can appear between tags. This means this new function should be used with ContentViewModel and SelectionInteractionContentViewModel.

html: String,
imageRetriever: T?,
customTagHandlers: Map<String, CustomTagHandler>
): String where T : Html.ImageGetter, T : ImageRetriever {
val handler = CustomHtmlContentHandler(customTagHandlers, imageRetriever)
HtmlCompat.fromHtml(
"<init-custom-handler/>$html",
HtmlCompat.FROM_HTML_MODE_LEGACY,
imageRetriever,
handler
)
Comment on lines +262 to +267
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code appears unused, what does it do?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is not unused, It triggers the HTML parsing process, allowing CustomHtmlContentHandler to intercept tags, extract content, and populate contentDescriptionBuilder to generate the final content description.

This approach leverages Android’s built-in HTML parsing system, ensuring compatibility with the framework’s expectations, and allows the reuse of the same parsing logic for both content description and display purposes.

I'll add comments explaining this and the pendingNewline in the PR to resolve:

This means this new function should be used with ContentViewModel and SelectionInteractionContentViewModel.

return handler.getContentDescription()
}
/**
* Returns a new [Spannable] with HTML parsed from [html] using the specified [imageRetriever]
* for handling image retrieval, and map of tags to [CustomTagHandler]s for handling custom
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ private const val CUSTOM_IMG_CAPTION_ATTRIBUTE = "caption-with-value"
*/
class ImageTagHandler(
private val consoleLogger: ConsoleLogger
) : CustomHtmlContentHandler.CustomTagHandler {
) : CustomHtmlContentHandler.CustomTagHandler, CustomHtmlContentHandler.ContentDescriptionProvider {
override fun handleTag(
attributes: Attributes,
openIndex: Int,
Expand Down Expand Up @@ -101,4 +101,11 @@ class ImageTagHandler(
"Failed to parse $CUSTOM_IMG_CAPTION_ATTRIBUTE"
)
}

override fun getContentDescription(attributes: Attributes): String {
val altValue = attributes.getJsonStringValue(CUSTOM_IMG_ALT_TEXT_ATTRIBUTE)
return if (!altValue.isNullOrBlank()) {
"Image illustrating $altValue"
} else ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.text.Editable
import android.text.Spannable
import android.text.Spanned
import org.oppia.android.util.locale.OppiaLocale
import org.xml.sax.Attributes
import java.util.Stack

/** The custom <li> tag corresponding to [LiTagHandler]. */
Expand All @@ -23,7 +24,7 @@ const val CUSTOM_LIST_OL_TAG = "oppia-ol"
class LiTagHandler(
private val context: Context,
private val displayLocale: OppiaLocale.DisplayLocale
) : CustomHtmlContentHandler.CustomTagHandler {
) : CustomHtmlContentHandler.CustomTagHandler, CustomHtmlContentHandler.ContentDescriptionProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since getContentDescription doesn't do anything here, I don't think
CustomHtmlContentHandler.ContentDescriptionProvider should be implemented here.

private val pendingLists = Stack<ListTag<*, *>>()
private val latestPendingList: ListTag<*, *>?
get() = pendingLists.lastOrNull()
Expand Down Expand Up @@ -291,4 +292,8 @@ class LiTagHandler(
private fun <T : Mark<*>> Spannable.addMark(mark: T) =
setSpan(mark, length, length, Spanned.SPAN_MARK_MARK)
}

override fun getContentDescription(attributes: Attributes): String {
return ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class MathTagHandler(
private val lineHeight: Float,
private val cacheLatexRendering: Boolean,
private val application: Application
) : CustomHtmlContentHandler.CustomTagHandler {
) : CustomHtmlContentHandler.CustomTagHandler, CustomHtmlContentHandler.ContentDescriptionProvider {
override fun handleTag(
attributes: Attributes,
openIndex: Int,
Expand Down Expand Up @@ -138,4 +138,9 @@ class MathTagHandler(
}
}
}

override fun getContentDescription(attributes: Attributes): String {
val mathVal = attributes.getJsonObjectValue(CUSTOM_MATH_MATH_CONTENT_ATTRIBUTE)
return mathVal?.let { "Math content $it" } ?: ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ private const val TERMS_OF_SERVICE = "Terms of Service"
class PolicyPageTagHandler(
private val listener: PolicyPageLinkClickListener,
private val consoleLogger: ConsoleLogger
) : CustomHtmlContentHandler.CustomTagHandler {
) : CustomHtmlContentHandler.CustomTagHandler, CustomHtmlContentHandler.ContentDescriptionProvider {
override fun handleTag(
attributes: Attributes,
openIndex: Int,
Expand Down Expand Up @@ -83,4 +83,11 @@ class PolicyPageTagHandler(
*/
fun onPolicyPageLinkClicked(policyType: PolicyType)
}

override fun getContentDescription(attributes: Attributes): String {
return when (attributes.getJsonStringValue("link")) {
TERMS_OF_SERVICE_PAGE, PRIVACY_POLICY_PAGE -> "Link to "
else -> ""
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ class ConceptCardTagHandlerTest {
assertThat(clickableSpans).hasLength(1)
}

@Test
adhiamboperes marked this conversation as resolved.
Show resolved Hide resolved
fun testGetContentDescription_withConceptCardTag() {
val contentDescription =
CustomHtmlContentHandler.getContentDescription(
html = CONCEPT_CARD_LINK_MARKUP_1,
imageRetriever = mockImageRetriever,
customTagHandlers = tagHandlersWithConceptCardSupport
)
assertThat(contentDescription).isEqualTo("refresher lesson concept card skill_id_1")
}

@Test
fun testParseHtml_withConceptCardMarkup_addsLinkText() {
val parsedHtml =
Expand Down
Loading
Loading