diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8b199f77525..efad241eca8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,5 +32,6 @@ android:theme="@style/SplashScreenTheme"> + diff --git a/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt b/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt new file mode 100644 index 00000000000..efea356c7e3 --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt @@ -0,0 +1,25 @@ +package org.oppia.app.testing + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.text.Spannable +import android.widget.TextView +import org.oppia.app.R +import org.oppia.util.parser.HtmlParser + +/** This is a dummy activity to test Html parsing. */ +class HtmlParserTestActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_test_html_parser) + + val testHtmlContentTextView = findViewById(R.id.test_html_content_text_view) as TextView + val rawDummyString = "\u003cp\u003e\"Let's try one last question,\" said Mr. Baker. \"Here's a pineapple cake cut into pieces.\"\u003c/p\u003e\u003coppia-noninteractive-image alt-with-value=\"\u0026amp;quot;Pineapple cake with 7/9 having cherries.\u0026amp;quot;\" caption-with-value=\"\u0026amp;quot;\u0026amp;quot;\" filepath-with-value=\"\u0026amp;quot;pineapple_cake_height_479_width_480.png\u0026amp;quot;\"\u003e\u003c/oppia-noninteractive-image\u003e\u003cp\u003e\u00a0\u003c/p\u003e\u003cp\u003e\u003cstrong\u003eQuestion 6\u003c/strong\u003e: What fraction of the cake has big red cherries in the pineapple slices?\u003c/p\u003e" + val htmlResult: Spannable = HtmlParser(applicationContext, /* entityType= */ "", /* entityId= */ "").parseOppiaHtml( + rawDummyString, + testHtmlContentTextView + ) + testHtmlContentTextView.text = htmlResult + } +} diff --git a/app/src/main/res/layout/activity_test_html_parser.xml b/app/src/main/res/layout/activity_test_html_parser.xml new file mode 100644 index 00000000000..aeb5d7a348f --- /dev/null +++ b/app/src/main/res/layout/activity_test_html_parser.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/sharedTest/java/org/oppia/app/testing/HtmlParserTest.kt b/app/src/sharedTest/java/org/oppia/app/testing/HtmlParserTest.kt new file mode 100644 index 00000000000..82e95fa70bb --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/app/testing/HtmlParserTest.kt @@ -0,0 +1,85 @@ +package org.oppia.app.testing + +import android.app.Activity +import android.content.Intent +import android.content.pm.ActivityInfo +import android.text.Spannable +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.ActivityTestRule +import com.google.common.truth.Truth.assertThat +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertTrue +import junit.framework.TestCase.assertNotSame +import org.hamcrest.Matchers.not +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.app.R +import org.oppia.util.parser.HtmlParser + +/** Tests for [HtmlParser]. */ +@RunWith(AndroidJUnit4::class) +class HtmlParserTest { + + private lateinit var launchedActivity: Activity + + @get:Rule + var activityTestRule: ActivityTestRule = ActivityTestRule( + HtmlParserTestActivity::class.java, /* initialTouchMode= */ true, /* launchActivity= */ false + ) + + @Before + fun setUp() { + Intents.init() + val intent = Intent(Intent.ACTION_PICK) + launchedActivity = activityTestRule.launchActivity(intent) + } + + @Test + fun testHtmlContent_handleCustomOppiaTags_parsedHtmldisplaysStyledText() { + activityTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + activityTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + val textView = activityTestRule.activity.findViewById(R.id.test_html_content_text_view) as TextView + val htmlResult: Spannable = HtmlParser( + ApplicationProvider.getApplicationContext(), /* entityType= */ "", /* entityId= */ "" + ).parseOppiaHtml( + "\u003cp\u003e\"Let's try one last question,\" said Mr. Baker. \"Here's a pineapple cake cut into pieces.\"\u003c/p\u003e\u003coppia-noninteractive-image alt-with-value=\"\u0026amp;quot;Pineapple cake with 7/9 having cherries.\u0026amp;quot;\" caption-with-value=\"\u0026amp;quot;\u0026amp;quot;\" filepath-with-value=\"\u0026amp;quot;pineapple_cake_height_479_width_480.png\u0026amp;quot;\"\u003e\u003c/oppia-noninteractive-image\u003e\u003cp\u003e\u00a0\u003c/p\u003e\u003cp\u003e\u003cstrong\u003eQuestion 6\u003c/strong\u003e: What fraction of the cake has big red cherries in the pineapple slices?\u003c/p\u003e", textView + ) + assertThat(textView.text.toString()).isEqualTo(htmlResult.toString()) + onView(withId(R.id.test_html_content_text_view)).check(matches(isDisplayed())) + onView(withId(R.id.test_html_content_text_view)).check(matches(withText(textView.text.toString()))) + } + + @Test + fun testHtmlContent_nonCustomOppiaTags_notParsed() { + activityTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + activityTestRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + + val textView = activityTestRule.activity.findViewById(R.id.test_html_content_text_view) as TextView + val htmlResult: Spannable = HtmlParser( + ApplicationProvider.getApplicationContext(), /* entityType= */ "", /* entityId= */ "" + ).parseOppiaHtml( + "\u003cp\u003e\"Let's try one last question,\" said Mr. Baker. \"Here's a pineapple cake cut into pieces.\"\u003c/p\u003e\u003coppia--image alt-with-value=\"\u0026amp;quot;Pineapple cake with 7/9 having cherries.\u0026amp;quot;\" caption-with-value=\"\u0026amp;quot;\u0026amp;quot;\" filepath-value=\"\u0026amp;quot;pineapple_cake_height_479_width_480.png\u0026amp;quot;\"\u003e\u003c/oppia-noninteractive-image\u003e\u003cp\u003e\u00a0\u003c/p\u003e\u003cp\u003e\u003cstrongQuestion 6\u003c/strong\u003e: What fraction of the cake has big red cherries in the pineapple slices?\u003c/p\u003e", + textView + ) + // The two strings aren't equal because this HTML contains a Non-Oppia/Non-Html tag e.g. tag and attributes "filepath-value" which isn't parsed. + assertThat(textView.text.toString()).isNotEqualTo(htmlResult.toString()) + onView(withId(R.id.test_html_content_text_view)).check(matches(not(textView.text.toString()))) + } + + @After + fun tearDown() { + Intents.release() + } +} diff --git a/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt new file mode 100644 index 00000000000..cc774b19d9c --- /dev/null +++ b/utility/src/main/java/org/oppia/util/parser/HtmlParser.kt @@ -0,0 +1,40 @@ +package org.oppia.util.parser + +import android.content.Context +import android.text.Html +import android.text.Spannable +import android.widget.TextView + +private const val CUSTOM_IMG_TAG = "oppia-noninteractive-image" +private const val REPLACE_IMG_TAG = "img" +private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value" +private const val REPLACE_IMG_FILE_PATH_ATTRIBUTE = "src" + +/** Html Parser to parse custom Oppia tags with Android-compatible versions. */ +class HtmlParser(private val context: Context, private val entityType: String, private val entityId: String) { + + /** + * This method replaces custom Oppia tags with Android-compatible versions for a given raw HTML string, and returns the HTML [Spannable]. + * @param rawString rawString argument is the string from the string-content + * @param htmlContentTextView htmlContentTextView argument is the TextView, that need to be passed as argument to ImageGetter class for image parsing + * @return Spannable Spannable represents the styled text. + */ + fun parseOppiaHtml(rawString: String, htmlContentTextView: TextView): Spannable { + var htmlContent = rawString + if (htmlContent.contains(CUSTOM_IMG_TAG)) { + htmlContent = htmlContent.replace(CUSTOM_IMG_TAG, REPLACE_IMG_TAG, /* ignoreCase= */false); + htmlContent = htmlContent.replace( + CUSTOM_IMG_FILE_PATH_ATTRIBUTE, + REPLACE_IMG_FILE_PATH_ATTRIBUTE, /* ignoreCase= */false + ); + htmlContent = htmlContent.replace(""", "") + } + // TODO(#205): Integrate UrlImageParser below once it's available. + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + Html.fromHtml(htmlContent, Html.FROM_HTML_MODE_LEGACY, /* imageGetter= */null, /* tagHandler= */null) as Spannable + } else { + Html.fromHtml(htmlContent, /* imageGetter= */null, /* tagHandler= */null) as Spannable + } + + } +}