Skip to content

Commit 5970ee3

Browse files
authored
Merge pull request #720 from Crustack/fix/performance
Compress large text changes in ChangeHistory
2 parents c7628eb + cf0bb64 commit 5970ee3

File tree

13 files changed

+450
-11
lines changed

13 files changed

+450
-11
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ dependencies {
269269
}
270270
implementation("org.commonmark:commonmark:0.27.0")
271271
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.0")
272+
implementation("com.github.luben:zstd-jni:1.5.7-6@aar")
272273

273274
androidTestImplementation("androidx.room:room-testing:$roomVersion")
274275
androidTestImplementation("androidx.work:work-testing:2.9.1")
@@ -282,4 +283,5 @@ dependencies {
282283
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
283284
testImplementation("org.mockito:mockito-core:5.13.0")
284285
testImplementation("org.robolectric:robolectric:4.15.1")
286+
testImplementation("com.github.luben:zstd-jni:1.5.7-6")
285287
}

app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory(
349349

350350
override fun afterTextChanged(s: Editable?) {
351351
val textAfter = requireNotNull(s, { "afterTextChanged: Editable is null" }).clone()
352-
if (textAfter.hasNotChanged(stateBefore.text)) {
352+
if (textAfter.hasNotChanged(stateBefore.getEditableText())) {
353353
return
354354
}
355355
updateModel.invoke(textAfter)

app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditActivity.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,11 @@ abstract class EditActivity(private val type: Type) :
176176
}
177177
}
178178

179-
open suspend fun saveNote(checkAutoSave: Boolean = true) {
179+
open suspend fun saveNote(checkAutoSave: Boolean = true): Long {
180180
updateModel()
181-
notallyModel.saveNote(checkAutoSave)
182-
WidgetProvider.sendBroadcast(application, longArrayOf(notallyModel.id))
181+
return notallyModel.saveNote(checkAutoSave).also {
182+
WidgetProvider.sendBroadcast(application, longArrayOf(it))
183+
}
183184
}
184185

185186
override fun onCreate(savedInstanceState: Bundle?) {

app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditTextPlainActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,11 @@ class EditTextPlainActivity : EditActivity(Type.NOTE) {
226226
private fun convertToTextNote() {
227227
lifecycleScope.launch {
228228
// Save the current note
229-
saveNote(checkAutoSave = false)
229+
val noteId = saveNote(checkAutoSave = false)
230230

231231
// Create a new intent to open the note in EditNoteActivity
232232
val intent = Intent(this@EditTextPlainActivity, EditNoteActivity::class.java)
233-
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, notallyModel.id)
233+
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, noteId)
234234
startActivity(intent)
235235
finish()
236236
}

app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ class ListManager(
287287
endSearch?.invoke()
288288
// }
289289
val item = items[position]
290-
item.body = value.text.toString()
290+
item.body = value.getEditableText().toString()
291291
if (pushChange) {
292292
changeHistory.push(ListEditTextChange(stateBefore, getState(), this))
293293
// TODO: fix focus change
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.philkes.notallyx.utils
2+
3+
import android.util.Log
4+
import com.github.luben.zstd.Zstd
5+
import com.philkes.notallyx.data.model.Converters
6+
import com.philkes.notallyx.data.model.SpanRepresentation
7+
import org.json.JSONObject
8+
9+
/**
10+
* Compression utilities for large text payloads to decrease memory usage by leveraging ZSTD to
11+
* de-/compress text + spans (JSON)
12+
*/
13+
object CompressUtility {
14+
15+
private const val TAG = "CompressUtility"
16+
private const val TEXT_FIELD = "text"
17+
private const val SPANS_FIELD = "spans"
18+
19+
// Threshold in characters for when to compress text (approximately 10KB)
20+
const val COMPRESSION_THRESHOLD: Int = 10_000
21+
22+
/** Compresses text and spans using GZIP compression into a ByteArray. */
23+
fun compressTextAndSpans(text: String, spans: List<SpanRepresentation>): ByteArray {
24+
val jsonObject = JSONObject()
25+
jsonObject.put(TEXT_FIELD, text)
26+
jsonObject.put(SPANS_FIELD, Converters.spansToJSONArray(spans))
27+
val bytes = jsonObject.toString().toByteArray(Charsets.UTF_8)
28+
return Zstd.compress(bytes, 4)
29+
}
30+
31+
/** Decompresses text and spans that were compressed with GZIP. */
32+
fun decompressTextAndSpans(compressedData: ByteArray): Pair<String, List<SpanRepresentation>> {
33+
val decompressedSize = Zstd.getFrameContentSize(compressedData)
34+
if (decompressedSize <= 0) {
35+
Log.e(
36+
TAG,
37+
"Invalid compressed data (frameContentSize: $decompressedSize), returning empty",
38+
)
39+
return Pair("", emptyList())
40+
} else {
41+
val result = ByteArray(decompressedSize.toInt())
42+
Zstd.decompress(result, compressedData)
43+
val jsonString = result.toString(Charsets.UTF_8)
44+
val jsonObject = JSONObject(jsonString)
45+
val text = jsonObject.getString(TEXT_FIELD)
46+
val spansArray = jsonObject.getJSONArray(SPANS_FIELD)
47+
val spans = Converters.jsonToSpans(spansArray)
48+
return Pair(text, spans)
49+
}
50+
}
51+
}

app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import android.util.Log
44
import com.philkes.notallyx.presentation.view.misc.NotNullLiveData
55
import kotlin.IllegalStateException
66

7-
class ChangeHistory {
7+
class ChangeHistory(
8+
/** Maximum number of changes to keep in memory. Oldest entries are evicted when full. */
9+
private val maxSize: Int = 1000
10+
) {
811
private val changeStack = ArrayList<Change>()
912
var stackPointer = NotNullLiveData(-1)
1013

@@ -19,9 +22,19 @@ class ChangeHistory {
1922
}
2023

2124
fun push(change: Change) {
25+
// Drop all redo entries after current pointer
2226
popRedos()
27+
// If full, evict the oldest entry and shift the pointer accordingly
28+
var newStackPointer = stackPointer.value
29+
if (changeStack.size >= maxSize) {
30+
if (changeStack.isNotEmpty()) {
31+
changeStack.removeAt(0)
32+
// Shift pointer left because we removed the head
33+
newStackPointer = (newStackPointer - 1).coerceAtLeast(-1)
34+
}
35+
}
2336
changeStack.add(change)
24-
stackPointer.value += 1
37+
stackPointer.value = newStackPointer + 1
2538
Log.d(TAG, "addChange: $change")
2639
}
2740

app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
package com.philkes.notallyx.utils.changehistory
22

3+
import android.graphics.Typeface
34
import android.text.Editable
5+
import android.text.SpannableStringBuilder
6+
import android.text.style.CharacterStyle
7+
import android.text.style.StrikethroughSpan
8+
import android.text.style.StyleSpan
9+
import android.text.style.TypefaceSpan
10+
import android.text.style.URLSpan
411
import androidx.core.text.getSpans
12+
import com.philkes.notallyx.data.model.SpanRepresentation
13+
import com.philkes.notallyx.presentation.applySpans
514
import com.philkes.notallyx.presentation.clone
615
import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory
716
import com.philkes.notallyx.presentation.view.misc.highlightableview.HighlightSpan
17+
import com.philkes.notallyx.utils.CompressUtility
818

919
class EditTextWithHistoryChange(
1020
private val editText: StylableEditTextWithHistory,
@@ -15,7 +25,7 @@ class EditTextWithHistoryChange(
1525

1626
override fun update(value: EditTextState, isUndo: Boolean) {
1727
editText.applyWithoutTextWatcher {
18-
val text = value.text.withoutSpans<HighlightSpan>()
28+
val text = value.getEditableText().withoutSpans<HighlightSpan>()
1929
setText(text)
2030
updateModel.invoke(text)
2131
requestFocus()
@@ -24,7 +34,103 @@ class EditTextWithHistoryChange(
2434
}
2535
}
2636

27-
data class EditTextState(val text: Editable, val cursorPos: Int)
37+
/**
38+
* Represents the state of an EditText, storing either the full text or a compressed version for
39+
* large text to reduce memory usage.
40+
*/
41+
class EditTextState(text: Editable, val cursorPos: Int) {
42+
companion object {}
43+
44+
// Either Editable (for small text) or ByteArray (compressed, for large text and spans)
45+
private val textContent: Any
46+
47+
init {
48+
// Extract spans from the Editable
49+
// Compress text and spans together
50+
this.textContent =
51+
if (text.length > CompressUtility.COMPRESSION_THRESHOLD) {
52+
// Extract spans from the Editable
53+
val spans = extractSpansFromEditable(text)
54+
// Compress text and spans together
55+
CompressUtility.compressTextAndSpans(
56+
text.toString(),
57+
spans as List<SpanRepresentation>,
58+
)
59+
} else {
60+
text
61+
}
62+
}
63+
64+
/** Extracts spans from an Editable and converts them to SpanRepresentation objects. */
65+
private fun extractSpansFromEditable(text: Editable): List<SpanRepresentation> {
66+
val representations = mutableListOf<SpanRepresentation>()
67+
68+
text.getSpans(0, text.length, CharacterStyle::class.java).forEach { span ->
69+
val end = text.getSpanEnd(span)
70+
val start = text.getSpanStart(span)
71+
72+
// Skip invalid spans
73+
if (start < 0 || end < 0 || start >= text.length || end > text.length) {
74+
return@forEach
75+
}
76+
77+
val representation =
78+
SpanRepresentation(
79+
start = start,
80+
end = end,
81+
bold = false,
82+
link = false,
83+
linkData = null,
84+
italic = false,
85+
monospace = false,
86+
strikethrough = false,
87+
)
88+
89+
when (span) {
90+
is StyleSpan ->
91+
when (span.style) {
92+
Typeface.BOLD -> representation.bold = true
93+
Typeface.ITALIC -> representation.italic = true
94+
Typeface.BOLD_ITALIC -> {
95+
representation.bold = true
96+
representation.italic = true
97+
}
98+
}
99+
100+
is URLSpan -> {
101+
representation.link = true
102+
representation.linkData = span.url
103+
}
104+
is TypefaceSpan -> {
105+
if (span.family == "monospace") {
106+
representation.monospace = true
107+
}
108+
}
109+
is StrikethroughSpan -> {
110+
representation.strikethrough = true
111+
}
112+
}
113+
114+
if (representation.isNotUseless()) {
115+
representations.add(representation)
116+
}
117+
}
118+
119+
return representations
120+
}
121+
122+
/** Returns the Editable text, decompressing it if necessary and applying spans. */
123+
fun getEditableText(): Editable {
124+
return when (textContent) {
125+
is Editable -> textContent
126+
is ByteArray -> {
127+
val (text, spans) = CompressUtility.decompressTextAndSpans(textContent)
128+
text.applySpans(spans)
129+
}
130+
else -> SpannableStringBuilder()
131+
}
132+
}
133+
}
28134

29135
inline fun <reified T : Any> Editable.withoutSpans(): Editable =
30136
clone().apply { this.getSpans<T>().forEach { removeSpan(it) } }

app/src/test/kotlin/com/philkes/notallyx/changehistory/ChangeHistoryTest.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,67 @@ class ChangeHistoryTest {
138138

139139
override fun undo() {}
140140
}
141+
142+
@Test
143+
fun `bounded history evicts oldest when capacity reached`() {
144+
// Use a small capacity to test eviction behavior
145+
changeHistory = ChangeHistory(maxSize = 3)
146+
val c1 = TestChange()
147+
val c2 = TestChange()
148+
val c3 = TestChange()
149+
val c4 = TestChange()
150+
151+
changeHistory.push(c1)
152+
changeHistory.push(c2)
153+
changeHistory.push(c3)
154+
// Next push should evict c1
155+
changeHistory.push(c4)
156+
157+
assertTrue(changeHistory.canUndo.value)
158+
// Top of stack is c4
159+
assertEquals(c4, changeHistory.lookUp())
160+
// Next is c3
161+
assertEquals(c3, changeHistory.lookUp(1))
162+
// Next is c2
163+
assertEquals(c2, changeHistory.lookUp(2))
164+
// c1 should be gone
165+
assertThrows(ChangeHistory.ChangeHistoryException::class.java) { changeHistory.lookUp(3) }
166+
}
167+
168+
@Test
169+
fun `pushing after undo with full capacity drops redos then evicts oldest if needed`() {
170+
changeHistory = ChangeHistory(maxSize = 3)
171+
val c1 = TestChange()
172+
val c2 = TestChange()
173+
val c3 = TestChange()
174+
val c4 = TestChange()
175+
val c5 = TestChange()
176+
177+
// Fill to capacity
178+
changeHistory.push(c1)
179+
changeHistory.push(c2)
180+
changeHistory.push(c3)
181+
182+
// Undo 1 -> pointer at c2
183+
changeHistory.undo()
184+
assertTrue(changeHistory.canRedo.value)
185+
186+
// Push new change: should drop redo (c3) first, then possibly evict if full when pushing
187+
changeHistory.push(c4)
188+
189+
// Stack should now have [c1, c2, c4] with pointer at top
190+
assertEquals(c4, changeHistory.lookUp())
191+
assertEquals(c2, changeHistory.lookUp(1))
192+
assertEquals(c1, changeHistory.lookUp(2))
193+
194+
// Push another to force eviction of c1
195+
changeHistory.push(c5)
196+
assertEquals(c5, changeHistory.lookUp())
197+
assertEquals(c4, changeHistory.lookUp(1))
198+
assertEquals(c2, changeHistory.lookUp(2))
199+
assertThrows(ChangeHistory.ChangeHistoryException::class.java) { changeHistory.lookUp(3) }
200+
201+
// No redo available after pushes
202+
assertFalse(changeHistory.canRedo.value)
203+
}
141204
}

app/src/test/kotlin/com/philkes/notallyx/test/TestUtils.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ fun mockAndroidLog() {
3030
every { Log.v(any(), any()) } returns 0
3131
every { Log.d(any(), any()) } returns 0
3232
every { Log.i(any(), any()) } returns 0
33+
every { Log.w(any<String>(), any<String>()) } returns 0
34+
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
3335
every { Log.e(any(), any()) } returns 0
3436
}
3537

0 commit comments

Comments
 (0)