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
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
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
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 26, 2026

✅ Actions performed

Full review triggered.

Copy link
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)
@Crustack
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link
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
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 {}
@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

1 participant