Skip to content

Compress large text changes in ChangeHistory#720

Merged
Crustack merged 3 commits intomainfrom
fix/performance
Jan 26, 2026
Merged

Compress large text changes in ChangeHistory#720
Crustack merged 3 commits intomainfrom
fix/performance

Conversation

@Crustack
Copy link
Copy Markdown
Owner

@Crustack Crustack commented Nov 10, 2025

Related issues:

This PR tries to improve ChangeHistory performance by using zstd to compress large text changes (>10KB) in the ChangeHistory instead of stacking up those huge strings

Summary by CodeRabbit

  • New Features

    • Zstandard-based automatic compression for large note content, preserving rich-text formatting and spans.
  • Improvements

    • Undo/Redo now uses a bounded history (default 1,000 entries), evicting oldest entries when full and dropping redo after new edits.
    • Note save flow now returns the saved note ID and uses it for downstream navigation and widget updates.
  • Tests

    • Added comprehensive tests for compression round-trip and bounded history behavior.
  • Documentation

    • Note about the 1,000-change Undo/Redo limit added.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 10, 2025

📝 Walkthrough

Walkthrough

Adds Zstd-based compression for text+spans with a 10_000-byte threshold, refactors EditTextState to store compressed payloads with getEditableText(), bounds ChangeHistory with a maxSize and eviction, updates call sites to use getEditableText(), and adds tests and a zstd-jni dependency.

Changes

Cohort / File(s) Summary
Build Configuration
app/build.gradle.kts
Added Zstd-JNI dependency com.github.luben:zstd-jni:1.5.7-6 to implementation and testImplementation.
Compression Utility
app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt
New CompressUtility object: JSON-encodes text+spans, compresses with Zstd (level 4), exposes compressTextAndSpans and decompressTextAndSpans, and defines COMPRESSION_THRESHOLD = 10_000.
EditText State & Call Sites
app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt, app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt, app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt
EditTextState changed from data class to class that stores either an Editable or compressed ByteArray; added getEditableText() which decompresses and reapplies spans. Callers updated to use getEditableText() for comparisons and updates.
ChangeHistory
app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt
Constructor adds maxSize: Int = 1000; push now drops redo entries, evicts oldest when full, and adjusts the pointer accordingly.
EditActivity flow
app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditActivity.kt, app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditTextPlainActivity.kt
saveNote now returns Long (note ID); callers capture returned ID and pass it (e.g., EXTRA_SELECTED_BASE_NOTE) and use it for widget broadcasts.
Tests — Compression & History
app/src/test/kotlin/.../utils/CompressUtilityTest.kt, app/src/test/kotlin/.../utils/EditTextCompressionTest.kt, app/src/test/kotlin/.../changehistory/ChangeHistoryTest.kt
New tests validating CompressUtility round-trip, EditTextState compression/decompression and span preservation, and ChangeHistory eviction/redo behaviors.
Test Utilities
app/src/test/kotlin/com/philkes/notallyx/test/TestUtils.kt
Added mocks for Log.w(String, String) and Log.w(String, String, Throwable) to silence warning logs in tests.
Documentation
documentation/docs/features/rich-text.mdx
Added note: "Undo/Redo is limited to the last 1,000 changes."

Sequence Diagram(s)

sequenceDiagram
    participant UI as EditText (UI)
    participant State as EditTextState
    participant Compress as CompressUtility
    participant History as ChangeHistory

    UI->>State: update(value.getEditableText())
    State->>State: extract text & spans, measure size
    alt size > COMPRESSION_THRESHOLD
        State->>Compress: compressTextAndSpans(text, spans)
        Compress-->>State: compressed ByteArray
        State-->>History: push(state with compressed payload)
    else small payload
        State-->>History: push(state with Editable)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I tuck spans in tiny Zstd bites,

Bytes snug tight through days and nights,
EditText hums both small and grand,
History hops with a thousand-band,
🥕 Compression cheers — the rabbit’s bright lights!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective of the changeset: introducing compression for large text changes stored in ChangeHistory using ZSTD.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as outdated.

@Crustack
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 26, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt`:
- Around line 89-96: The when-block handling StyleSpan in
EditTextWithHistoryChange (around the code that checks span.style against
Typeface.BOLD and Typeface.ITALIC) currently ignores Typeface.BOLD_ITALIC;
update the logic to detect combined styles (either by checking span.style ==
Typeface.BOLD_ITALIC or by bitwise-testing flags, e.g., (span.style &
Typeface.BOLD) != 0 and (span.style & Typeface.ITALIC) != 0) and set
representation.bold = true and representation.italic = true accordingly so both
attributes are preserved for BOLD_ITALIC spans.

In `@app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt`:
- Around line 38-52: Wrap the body of decompressTextAndSpans in a try/catch that
handles ZstdException, JSONException and a general Exception so corrupted input
doesn't crash the app; on exception log the error (e.g., Log.e or existing
logger) with context mentioning "decompressTextAndSpans" and the caught
exception, and return a safe fallback such as Pair("", emptyList()) instead of
letting the exception bubble up—ensure you still reference the existing symbols
(decompressTextAndSpans, Zstd.decompressedSize/Zstd.decompress, JSONObject,
Converters.jsonToSpans) when updating the method.
♻️ Duplicate comments (1)
app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt (1)

16-16: Correct the misleading comments referencing GZIP.

The comments on lines 16, 28, and 37 state "GZIP" but the implementation uses Zstd compression. This was flagged in a previous review.

📝 Suggested fix
 /**
  * Shared compression utilities for large text payloads to decrease memory and storage usage.
- * - For EditText state (text + spans), we store a GZIP-compressed JSON as ByteArray.
+ * - For EditText state (text + spans), we store a Zstd-compressed JSON as ByteArray.
  */
-    /** Compresses text and spans using GZIP compression into a ByteArray. */
+    /** Compresses text and spans using Zstd compression into a ByteArray. */
     fun compressTextAndSpans(text: String, spans: List<SpanRepresentation>): ByteArray {
-    /** Decompresses text and spans that were compressed with GZIP. */
+    /** Decompresses text and spans that were compressed with Zstd. */
     fun decompressTextAndSpans(compressedData: ByteArray): Pair<String, List<SpanRepresentation>> {

Also applies to: 28-28, 37-37

🧹 Nitpick comments (4)
app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt (1)

43-46: Remove commented-out code.

This dead code should be removed to improve maintainability.

🧹 Suggested fix
         val jsonString = result.toString(Charsets.UTF_8)
-        //        val bis = ByteArrayInputStream(compressedData)
-        //        val jsonString = GZIPInputStream(bis).use { gzipIS ->
-        //            gzipIS.readBytes().toString(Charsets.UTF_8)
-        //        }
         val jsonObject = JSONObject(jsonString)
app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt (2)

55-58: Remove unnecessary cast.

The extractSpansFromEditable already returns List<SpanRepresentation>, so the cast is redundant.

🧹 Suggested fix
             if (text.length > CompressUtility.COMPRESSION_THRESHOLD) {
                 // Extract spans from the Editable
                 val spans = extractSpansFromEditable(text)
                 // Compress text and spans together
-                CompressUtility.compressTextAndSpans(
-                    text.toString(),
-                    spans as List<SpanRepresentation>,
-                )
+                CompressUtility.compressTextAndSpans(text.toString(), spans)
             } else {

41-42: Remove empty companion object.

The empty companion object {} serves no purpose.

🧹 Suggested fix
 class EditTextState(text: Editable, val cursorPos: Int) {
-    companion object {}
-
     // Either Editable (for small text) or ByteArray (compressed, for large text and spans)
app/src/test/kotlin/com/philkes/notallyx/utils/EditTextCompressionTest.kt (1)

31-45: Avoid hard-coding the compression prefix in tests.
This couples the test to the current wire format. Using equality/size checks keeps the test stable if the prefix or algorithm changes.

♻️ Suggested test tweak
-        // exact threshold should not be compressed
-        assertFalse(atThreshold.startsWith("GZ:"))
+        // exact threshold should not be compressed
+        assertEquals(base, atThreshold)
         assertEquals(base, CompressUtility.decompressIfNeeded(atThreshold))

         val above = base + "y"
         val compressed = CompressUtility.compressIfNeeded(above)
-        // above threshold should be compressed and round-trip correctly
-        assertTrue(compressed.startsWith("GZ:"))
+        // above threshold should be compressed and round-trip correctly
+        assertTrue(compressed.length < above.length)
         val roundTrip = CompressUtility.decompressIfNeeded(compressed)
         assertEquals(above, roundTrip)

Comment thread app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt Outdated
@Crustack
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt`:
- Around line 19-20: The comment and the constant COMPRESSION_THRESHOLD in
CompressUtility.kt disagree (comment says ~10KB but PR summary said 5KB); decide
which is correct and make them consistent by either setting
COMPRESSION_THRESHOLD to 5_000 if the intended threshold is 5KB or updating the
inline comment to reflect 10_000 (~10KB) if 10KB is intended; ensure the
constant name COMPRESSION_THRESHOLD and its explanatory comment are updated
together so documentation matches the actual value used by the compression
logic.
🧹 Nitpick comments (2)
app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt (1)

24-39: Minor simplification possible in eviction logic.

The inner if (changeStack.isNotEmpty()) check on line 30 is redundant since changeStack.size >= maxSize (where maxSize >= 1) guarantees the stack is non-empty.

♻️ Optional simplification
     fun push(change: Change) {
         // Drop all redo entries after current pointer
         popRedos()
         // If full, evict the oldest entry and shift the pointer accordingly
         var newStackPointer = stackPointer.value
         if (changeStack.size >= maxSize) {
-            if (changeStack.isNotEmpty()) {
-                changeStack.removeAt(0)
-                // Shift pointer left because we removed the head
-                newStackPointer = (newStackPointer - 1).coerceAtLeast(-1)
-            }
+            changeStack.removeAt(0)
+            // Shift pointer left because we removed the head
+            newStackPointer = (newStackPointer - 1).coerceAtLeast(-1)
         }
         changeStack.add(change)
         stackPointer.value = newStackPointer + 1
         Log.d(TAG, "addChange: $change")
     }
app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt (1)

41-43: Remove the empty companion object.

It’s unused and can be dropped for clarity.

♻️ Suggested cleanup
 class EditTextState(text: Editable, val cursorPos: Int) {
-    companion object {}

Comment thread app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt
@Crustack Crustack merged commit 5970ee3 into main Jan 26, 2026
1 check passed
@Crustack Crustack deleted the fix/performance branch January 26, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant