diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java
index 2c148649d..8df4fac58 100644
--- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java
+++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java
@@ -18,6 +18,8 @@
import static it.niedermann.android.markdown.MarkdownUtil.removeMarkdown;
import static it.niedermann.android.markdown.MarkdownUtil.replaceCheckboxesWithEmojis;
+import java.util.regex.Pattern;
+
/**
* Provides basic functionality for Note operations.
*/
@@ -69,18 +71,49 @@ private static String truncateString(@NonNull String str, @SuppressWarnings("Sam
*/
@NonNull
public static String generateNoteExcerpt(@NonNull String content, @Nullable String title) {
- content = removeMarkdown(replaceCheckboxesWithEmojis(content.trim()));
- if (TextUtils.isEmpty(content)) {
+ final var trimmedContent = content.trim();
+
+ if (isHtml(trimmedContent)) {
+ return sanitizeHtml(trimmedContent);
+ }
+
+ final var emojiReplacedWithCheckBoxesContent = replaceCheckboxesWithEmojis(trimmedContent);
+ var result = removeMarkdown(emojiReplacedWithCheckBoxesContent);
+ if (TextUtils.isEmpty(result)) {
return "";
}
+
if (!TextUtils.isEmpty(title)) {
- assert title != null;
final String trimmedTitle = removeMarkdown(replaceCheckboxesWithEmojis(title.trim()));
- if (content.startsWith(trimmedTitle)) {
- content = content.substring(trimmedTitle.length());
+ if (result.startsWith(trimmedTitle)) {
+ result = result.substring(trimmedTitle.length());
}
}
- return truncateString(content.trim(), 200).replace("\n", EXCERPT_LINE_SEPARATOR);
+
+ return truncateString(result.trim(), 200).replace("\n", EXCERPT_LINE_SEPARATOR);
+ }
+
+ private static final Pattern HTML_PATTERN = Pattern.compile(
+ "(?is)<(?:!DOCTYPE|/?(?:[a-z][a-z0-9]*))[^>]*>"
+ );
+
+ private static boolean isHtml(String content) {
+ if (content == null || content.isEmpty()) {
+ return false;
+ }
+
+ return HTML_PATTERN.matcher(content).find();
+ }
+
+ private static String sanitizeHtml(String html) {
+ // Remove script tags and their content
+ String sanitized = html.replaceAll("(?is)", "");
+
+ // Remove event handlers (onclick, onerror, onload, etc.)
+ sanitized = sanitized.replaceAll("(?i)\\s+on\\w+\\s*=\\s*['\"][^'\"]*['\"]", "");
+ sanitized = sanitized.replaceAll("(?i)\\s+on\\w+\\s*=\\s*[^\\s>]+", "");
+
+ return sanitized.trim();
}
@NonNull
diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.java b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.java
deleted file mode 100644
index 24d2a3307..000000000
--- a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.java
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Nextcloud Notes - Android Client
- *
- * SPDX-FileCopyrightText: 2015-2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-package it.niedermann.owncloud.notes.shared.util;
-
-import android.os.Build;
-
-import androidx.core.text.HtmlCompat;
-
-import junit.framework.TestCase;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
-
-import it.niedermann.android.markdown.MarkdownUtil;
-
-/**
- * Tests the NoteUtil.
- */
-@RunWith(RobolectricTestRunner.class)
-public class NoteUtilTest extends TestCase {
-
- @Test
- public void testIsEmptyLine() {
- assertTrue(NoteUtil.isEmptyLine(" "));
- assertTrue(NoteUtil.isEmptyLine("\n"));
- assertTrue(NoteUtil.isEmptyLine("\n "));
- assertTrue(NoteUtil.isEmptyLine(" \n"));
- assertTrue(NoteUtil.isEmptyLine(" \n "));
- assertFalse(NoteUtil.isEmptyLine("a \n "));
- }
-
- @Test
- public void testGetLineWithoutMarkdown() {
- assertEquals("Test", NoteUtil.getLineWithoutMarkdown("Test", 0));
- assertEquals("Test", NoteUtil.getLineWithoutMarkdown("\nTest", 0));
- assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 0));
- assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 1));
- assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("* Foo\n* Bar", 0));
- assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("- Foo\nBar", 1));
- assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("# Foo", 0));
- }
-
- @Test
- public void testGenerateNoteTitle() {
- assertEquals("Test", NoteUtil.generateNoteTitle("Test"));
- assertEquals("Test", NoteUtil.generateNoteTitle("Test\n"));
- assertEquals("Test", NoteUtil.generateNoteTitle("Test\nFoo"));
- assertEquals("Test", NoteUtil.generateNoteTitle("\nTest"));
- assertEquals("Test", NoteUtil.generateNoteTitle("\n\nTest"));
-
- // https://github.com/nextcloud/notes-android/issues/1104
- assertEquals("2021-03-24 - Example title", MarkdownUtil.removeMarkdown("2021-03-24 - Example title"));
- }
-
- @Test
- public void testGenerateNoteExcerpt() {
- // title is different from content → return max. 200 characters starting with the first line which is not empty
- assertEquals("Test", NoteUtil.generateNoteExcerpt("Test", "Title"));
- assertEquals("Test Foo", NoteUtil.generateNoteExcerpt("Test\nFoo", "Title"));
- assertEquals("Test Foo Bar", NoteUtil.generateNoteExcerpt("Test\nFoo\nBar", "Title"));
- assertEquals("", NoteUtil.generateNoteExcerpt("", "Title"));
-
- // content actually starts with title → return max. 200 characters starting with the first character after the title
- assertEquals("", NoteUtil.generateNoteExcerpt("Title", "Title"));
- assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "Title"));
- assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "Title"));
- assertEquals("", NoteUtil.generateNoteExcerpt("", "Title"));
-
- // some empty lines between the actual contents → Should be ignored
- assertEquals("", NoteUtil.generateNoteExcerpt("\nTitle", "Title"));
- assertEquals("Foo", NoteUtil.generateNoteExcerpt("\n\n\n\nTitle\nFoo", "Title"));
- assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("\nTitle\n\n\nTitle\nBar", "\n\nTitle"));
- assertEquals("", NoteUtil.generateNoteExcerpt("\n\n\n", "\nTitle"));
-
- // content has markdown while titles markdown is already stripped
- assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "Title"));
- assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\n- Foo", "Title"));
-
- // title has markdown while contents markdown is stripped
- assertEquals("", NoteUtil.generateNoteExcerpt("Title", "# Title"));
- assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "- Title"));
- assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "- Title"));
-
- // content and title have markdown
- assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "# Title"));
- assertEquals("Foo", NoteUtil.generateNoteExcerpt("# Title\n- Foo", "- Title"));
- assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("- Title\nTitle\nBar", "- Title"));
- }
-
- /**
- * Has known issues on {@link Build.VERSION_CODES#LOLLIPOP_MR1} and
- * {@link Build.VERSION_CODES#M} due to incompatibilities of
- * {@link HtmlCompat#fromHtml(String, int)}
- */
- @Test
- @Config(sdk = {30})
- public void testGenerateNoteExcerpt_sdk_30() {
- // content has markdown while titles markdown is already stripped
- assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("# Title\n- Title\n- Bar", "Title"));
- }
-}
diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt
new file mode 100644
index 000000000..bc6b87180
--- /dev/null
+++ b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt
@@ -0,0 +1,267 @@
+/*
+ * Nextcloud Notes - Android Client
+ *
+ * SPDX-FileCopyrightText: 2015-2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.niedermann.owncloud.notes.shared.util
+
+import android.os.Build
+import androidx.core.text.HtmlCompat
+import it.niedermann.android.markdown.MarkdownUtil
+import junit.framework.TestCase
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Tests the NoteUtil.
+ */
+@RunWith(RobolectricTestRunner::class)
+class NoteUtilTest : TestCase() {
+
+ @Test
+ fun testIsEmptyLine() {
+ assertTrue(NoteUtil.isEmptyLine(" "))
+ assertTrue(NoteUtil.isEmptyLine("\n"))
+ assertTrue(NoteUtil.isEmptyLine("\n "))
+ assertTrue(NoteUtil.isEmptyLine(" \n"))
+ assertTrue(NoteUtil.isEmptyLine(" \n "))
+ assertFalse(NoteUtil.isEmptyLine("a \n "))
+ }
+
+ @Test
+ fun testGetLineWithoutMarkdown() {
+ assertEquals("Test", NoteUtil.getLineWithoutMarkdown("Test", 0))
+ assertEquals("Test", NoteUtil.getLineWithoutMarkdown("\nTest", 0))
+ assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 0))
+ assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 1))
+ assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("* Foo\n* Bar", 0))
+ assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("- Foo\nBar", 1))
+ assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("# Foo", 0))
+ }
+
+ @Test
+ fun testGenerateNoteTitle() {
+ assertEquals("Test", NoteUtil.generateNoteTitle("Test"))
+ assertEquals("Test", NoteUtil.generateNoteTitle("Test\n"))
+ assertEquals("Test", NoteUtil.generateNoteTitle("Test\nFoo"))
+ assertEquals("Test", NoteUtil.generateNoteTitle("\nTest"))
+ assertEquals("Test", NoteUtil.generateNoteTitle("\n\nTest"))
+
+ // https://github.com/nextcloud/notes-android/issues/1104
+ assertEquals("2021-03-24 - Example title", MarkdownUtil.removeMarkdown("2021-03-24 - Example title"))
+ }
+
+ @Test
+ fun testGenerateNoteExcerpt() {
+ testBasicExcerpts()
+ testTitleMatchingExcerpts()
+ testEmptyLineHandling()
+ testMarkdownInContent()
+ testMarkdownInTitle()
+ testMarkdownInBoth()
+ testHtmlSanitization()
+ testEdgeCases()
+ testRealisticUserNotes()
+ }
+
+ private fun testBasicExcerpts() {
+ // title is different from content → return max. 200 characters starting with the first line which is not empty
+ assertEquals("Test", NoteUtil.generateNoteExcerpt("Test", "Title"))
+ assertEquals("Test Foo", NoteUtil.generateNoteExcerpt("Test\nFoo", "Title"))
+ assertEquals("Test Foo Bar", NoteUtil.generateNoteExcerpt("Test\nFoo\nBar", "Title"))
+ assertEquals("", NoteUtil.generateNoteExcerpt("", "Title"))
+ }
+
+ private fun testTitleMatchingExcerpts() {
+ // content actually starts with title → return max. 200 characters starting with the first character after the title
+ assertEquals("", NoteUtil.generateNoteExcerpt("Title", "Title"))
+ assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "Title"))
+ assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "Title"))
+ assertEquals("", NoteUtil.generateNoteExcerpt("", "Title"))
+ }
+
+ private fun testEmptyLineHandling() {
+ // some empty lines between the actual contents → Should be ignored
+ assertEquals("", NoteUtil.generateNoteExcerpt("\nTitle", "Title"))
+ assertEquals("Foo", NoteUtil.generateNoteExcerpt("\n\n\n\nTitle\nFoo", "Title"))
+ assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("\nTitle\n\n\nTitle\nBar", "\n\nTitle"))
+ assertEquals("", NoteUtil.generateNoteExcerpt("\n\n\n", "\nTitle"))
+ }
+
+ private fun testMarkdownInContent() {
+ // content has markdown while titles, markdown is already stripped
+ assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "Title"))
+ assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\n- Foo", "Title"))
+ }
+
+ private fun testMarkdownInTitle() {
+ // title has markdown while contents markdown is stripped
+ assertEquals("", NoteUtil.generateNoteExcerpt("Title", "# Title"))
+ assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "- Title"))
+ assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "- Title"))
+ }
+
+ private fun testMarkdownInBoth() {
+ // content and title have markdown
+ assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "# Title"))
+ assertEquals("Foo", NoteUtil.generateNoteExcerpt("# Title\n- Foo", "- Title"))
+ assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("- Title\nTitle\nBar", "- Title"))
+ }
+
+ private fun testHtmlSanitization() {
+ val html = """
+ click
+
+
+ """.trimIndent()
+
+ val expectedHtml = "click\n
"
+ assertEquals(expectedHtml, NoteUtil.generateNoteExcerpt(html, "Title"))
+ assertEquals(expectedHtml, NoteUtil.generateNoteExcerpt(html, null))
+
+ val scriptHtml = "title"
+ val scriptExcerpt = NoteUtil.generateNoteExcerpt(scriptHtml, "Test")
+ assertFalse("Excerpts should never contain executable scripts", scriptExcerpt.contains("