From 9f278093fb46466fe8098b934cbe28d15fa977e7 Mon Sep 17 00:00:00 2001 From: PhilKes Date: Sat, 1 Nov 2025 16:14:26 +0100 Subject: [PATCH] Compress large text changes in ChangeHistory --- app/build.gradle.kts | 1 + .../data/imports/evernote/EvernoteImporter.kt | 3 +- .../data/imports/google/GoogleKeepImporter.kt | 3 +- .../data/imports/markdown/MarkdownUtils.kt | 2 - .../data/imports/txt/PlainTextImporter.kt | 3 +- .../philkes/notallyx/data/model/BaseNote.kt | 2 +- .../philkes/notallyx/data/model/BodyString.kt | 13 ++ .../philkes/notallyx/data/model/Converters.kt | 9 ++ .../notallyx/data/model/ModelExtensions.kt | 6 +- .../notallyx/presentation/UiExtensions.kt | 8 +- .../note/reminders/ReminderReceiver.kt | 4 +- .../presentation/view/main/BaseNoteVH.kt | 6 +- .../view/note/listitem/ListManager.kt | 4 +- .../presentation/viewmodel/BaseNoteModel.kt | 9 +- .../presentation/viewmodel/NotallyModel.kt | 11 +- .../presentation/widget/WidgetFactory.kt | 2 +- .../notallyx/utils/AndroidExtensions.kt | 2 +- .../philkes/notallyx/utils/CompressUtility.kt | 89 ++++++++++++++ .../notallyx/utils/backup/ImportExtensions.kt | 3 +- .../utils/backup/XmlParserExtensions.kt | 3 +- .../utils/changehistory/ChangeHistory.kt | 7 ++ .../EditTextWithHistoryChange.kt | 115 +++++++++++++++++- .../imports/markdown/MarkdownUtilsTest.kt | 8 +- .../notallyx/data/model/MarkdownExportTest.kt | 4 +- .../imports/google/GoogleKeepImporterTest.kt | 3 +- .../data/model/ModelExtensionsTest.kt | 2 +- 26 files changed, 283 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/philkes/notallyx/data/model/BodyString.kt create mode 100644 app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 46cdd9e8..350236ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -269,6 +269,7 @@ dependencies { } implementation("org.commonmark:commonmark:0.27.0") implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.0") + implementation("com.github.luben:zstd-jni:1.5.6-7@aar") androidTestImplementation("androidx.room:room-testing:$roomVersion") androidTestImplementation("androidx.work:work-testing:2.9.1") diff --git a/app/src/main/java/com/philkes/notallyx/data/imports/evernote/EvernoteImporter.kt b/app/src/main/java/com/philkes/notallyx/data/imports/evernote/EvernoteImporter.kt index 26f3c518..e6335c6c 100644 --- a/app/src/main/java/com/philkes/notallyx/data/imports/evernote/EvernoteImporter.kt +++ b/app/src/main/java/com/philkes/notallyx/data/imports/evernote/EvernoteImporter.kt @@ -14,6 +14,7 @@ import com.philkes.notallyx.data.imports.evernote.EvernoteImporter.Companion.par import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.BaseNote +import com.philkes.notallyx.data.model.BodyString import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.ListItem @@ -149,7 +150,7 @@ fun EvernoteNote.mapToBaseNote(): BaseNote { timestamp = parseTimestamp(created), modifiedTimestamp = parseTimestamp(updated), labels = tag.map { it.name }, - body = body, + body = BodyString(body), spans = spans, items = tasks.mapToListItem(), images = images, diff --git a/app/src/main/java/com/philkes/notallyx/data/imports/google/GoogleKeepImporter.kt b/app/src/main/java/com/philkes/notallyx/data/imports/google/GoogleKeepImporter.kt index 0e2076a4..fac6a8ef 100644 --- a/app/src/main/java/com/philkes/notallyx/data/imports/google/GoogleKeepImporter.kt +++ b/app/src/main/java/com/philkes/notallyx/data/imports/google/GoogleKeepImporter.kt @@ -11,6 +11,7 @@ import com.philkes.notallyx.data.imports.ImportStage import com.philkes.notallyx.data.imports.parseBodyAndSpansFromHtml import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.BaseNote +import com.philkes.notallyx.data.model.BodyString import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.ListItem @@ -156,7 +157,7 @@ class GoogleKeepImporter : ExternalImporter { timestamp = googleKeepNote.createdTimestampUsec / 1000, modifiedTimestamp = googleKeepNote.userEditedTimestampUsec / 1000, labels = googleKeepNote.labels.map { it.name }, - body = body, + body = BodyString(body), spans = spans, items = items, images = images, diff --git a/app/src/main/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtils.kt b/app/src/main/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtils.kt index 1d1eb841..bf64b0af 100644 --- a/app/src/main/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtils.kt +++ b/app/src/main/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtils.kt @@ -73,7 +73,6 @@ fun parseBodyAndSpansFromMarkdown(input: String): Pair, - val body: String, + val body: BodyString, val spans: List, val items: List, val images: List, diff --git a/app/src/main/java/com/philkes/notallyx/data/model/BodyString.kt b/app/src/main/java/com/philkes/notallyx/data/model/BodyString.kt new file mode 100644 index 00000000..c1cff318 --- /dev/null +++ b/app/src/main/java/com/philkes/notallyx/data/model/BodyString.kt @@ -0,0 +1,13 @@ +package com.philkes.notallyx.data.model + +@JvmInline +value class BodyString(val value: String) { + override fun toString(): String = value + + fun isEmpty(): Boolean = value.isEmpty() + + fun isNotEmpty(): Boolean = value.isNotEmpty() + + fun contains(other: String, ignoreCase: Boolean = false): Boolean = + value.contains(other, ignoreCase) +} diff --git a/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt b/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt index af465417..496eea2b 100644 --- a/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt +++ b/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt @@ -8,6 +8,15 @@ import org.json.JSONObject object Converters { + // TypeConverter for BaseNote.body compression/decompression + @TypeConverter + fun fromBodyString(body: BodyString): String = + com.philkes.notallyx.utils.CompressUtility.compressIfNeeded(body.value) + + @TypeConverter + fun toBodyString(dbValue: String): BodyString = + BodyString(com.philkes.notallyx.utils.CompressUtility.decompressIfNeeded(dbValue)) + @TypeConverter fun labelsToJson(labels: List) = JSONArray(labels).toString() @TypeConverter fun jsonToLabels(json: String) = jsonToLabels(JSONArray(json)) diff --git a/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt b/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt index ae432523..875f0ea2 100644 --- a/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt @@ -134,7 +134,7 @@ fun String.toBaseNote(): BaseNote { timestamp, modifiedTimestamp, labels, - body, + BodyString(body), spans, items, images, @@ -193,7 +193,7 @@ fun BaseNote.toHtml(showDateCreated: Boolean, imagesRootFolder: File?) = buildSt when (type) { Type.NOTE -> { - val body = body.applySpans(spans).toHtml() + val body = body.value.applySpans(spans).toHtml() append(body) } @@ -232,7 +232,7 @@ fun List.toNoteIdReminders() = map { NoteIdReminder(it.id, it.reminder fun BaseNote.toMarkdown(): String = buildString { when (type) { Type.NOTE -> { - append(createMarkdownFromBodyAndSpans(body, spans)) + append(createMarkdownFromBodyAndSpans(body.value, spans)) } Type.LIST -> { append(items.toMarkdownChecklist()) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt b/app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt index ee9bde46..5e3bd380 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt @@ -107,6 +107,7 @@ import com.philkes.notallyx.presentation.viewmodel.preference.TextSize import com.philkes.notallyx.utils.changehistory.ChangeHistory import com.philkes.notallyx.utils.changehistory.EditTextState import com.philkes.notallyx.utils.changehistory.EditTextWithHistoryChange +import com.philkes.notallyx.utils.changehistory.lastChangeAs import com.philkes.notallyx.utils.getUrl import java.util.Date import me.zhanghai.android.fastscroll.FastScrollerBuilder @@ -340,7 +341,10 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory( private lateinit var stateBefore: EditTextState override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - stateBefore = EditTextState(getTextClone(), selectionStart) + // Get state before from last change's after state (if it is EditTextChange + stateBefore = + changeHistory.lastChangeAs()?.newValue + ?: EditTextState(getTextClone(), selectionStart) } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { @@ -349,7 +353,7 @@ fun StylableEditTextWithHistory.createTextWatcherWithHistory( override fun afterTextChanged(s: Editable?) { val textAfter = requireNotNull(s, { "afterTextChanged: Editable is null" }).clone() - if (textAfter.hasNotChanged(stateBefore.text)) { + if (textAfter.hasNotChanged(stateBefore.getEditableText())) { return } updateModel.invoke(textAfter) diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt index 14189281..d8f2ca6b 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt @@ -79,7 +79,9 @@ class ReminderReceiver : BroadcastReceiver() { NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.notebook) .setContentTitle(note.title) // Set title from intent - .setContentText(note.body.truncate(200)) // Set content text from intent + .setContentText( + note.body.value.truncate(200) + ) // Set content text from intent .setPriority(NotificationCompat.PRIORITY_HIGH) .addAction( R.drawable.visibility, diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt index df90dec0..9f9211a0 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt @@ -165,13 +165,13 @@ class BaseNoteVH( private fun bindNote(baseNote: BaseNote, keyword: String) { binding.LinearLayout.visibility = GONE if (keyword.isBlank()) { - bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty()) + bindNote(baseNote.body.value, baseNote.spans, baseNote.title.isEmpty()) return } binding.Note.apply { - val snippet = extractSearchSnippet(baseNote.body, keyword) + val snippet = extractSearchSnippet(baseNote.body.value, keyword) if (snippet == null) { - bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty()) + bindNote(baseNote.body.value, baseNote.spans, baseNote.title.isEmpty()) } else { showSearchSnippet(snippet) } diff --git a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt index f3574c24..420071f7 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/view/note/listitem/ListManager.kt @@ -43,7 +43,7 @@ data class ListState( */ class ListManager( private val recyclerView: RecyclerView, - private val changeHistory: ChangeHistory, + internal val changeHistory: ChangeHistory, private val preferences: NotallyXPreferences, private val inputMethodManager: InputMethodManager?, private val endSearch: (() -> Unit)?, @@ -287,7 +287,7 @@ class ListManager( endSearch?.invoke() // } val item = items[position] - item.body = value.text.toString() + item.body = value.getEditableText().toString() if (pushChange) { changeHistory.push(ListEditTextChange(stateBefore, getState(), this)) // TODO: fix focus change diff --git a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt index 24e68edd..1dc5ebb4 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/BaseNoteModel.kt @@ -59,7 +59,6 @@ import com.philkes.notallyx.presentation.viewmodel.preference.Theme import com.philkes.notallyx.presentation.viewmodel.progress.DeleteProgress import com.philkes.notallyx.presentation.viewmodel.progress.ExportNotesProgress import com.philkes.notallyx.utils.ActionMode -import com.philkes.notallyx.utils.Cache import com.philkes.notallyx.utils.MIME_TYPE_JSON import com.philkes.notallyx.utils.backup.clearAllFolders import com.philkes.notallyx.utils.backup.clearAllLabels @@ -162,10 +161,10 @@ class BaseNoteModel(private val app: Application) : AndroidViewModel(app) { // colors = baseNoteDao.getAllColorsAsync() reminders = baseNoteDao.getAllRemindersAsync() - allNotes?.removeObserver(allNotesObserver!!) - allNotesObserver = Observer { list -> Cache.list = list } - allNotes = baseNoteDao.getAllAsync() - allNotes!!.observeForever(allNotesObserver!!) + // allNotes?.removeObserver(allNotesObserver!!) + // allNotesObserver = Observer { list -> Cache.list = list } + // allNotes = baseNoteDao.getAllAsync() + // allNotes!!.observeForever(allNotesObserver!!) labelsHiddenObserver?.let { preferences.labelsHidden.removeObserver(it) } labelsHiddenObserver = Observer { labelsHidden -> diff --git a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt index 264049fb..342fdfc5 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/viewmodel/NotallyModel.kt @@ -23,6 +23,7 @@ import com.philkes.notallyx.data.imports.txt.extractListItems import com.philkes.notallyx.data.imports.txt.findListSyntaxRegex import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.BaseNote +import com.philkes.notallyx.data.model.BodyString import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.ListItem @@ -227,8 +228,10 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) { if (id != 0L) { isNewNote = false - val cachedNote = Cache.list.find { baseNote -> baseNote.id == id } - val baseNote = cachedNote ?: withContext(Dispatchers.IO) { baseNoteDao.get(id) } + // val cachedNote = Cache.list.find { baseNote -> baseNote.id == id } + // val baseNote = cachedNote ?: withContext(Dispatchers.IO) { + // baseNoteDao.get(id) } + val baseNote = withContext(Dispatchers.IO) { baseNoteDao.get(id) } if (baseNote != null) { originalNote = baseNote.deepCopy() @@ -244,7 +247,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) { setLabels(baseNote.labels) - body = baseNote.body.applySpans(baseNote.spans) + body = baseNote.body.value.applySpans(baseNote.spans) items.clear() items.addAll(baseNote.items) @@ -346,7 +349,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) { timestamp, modifiedTimestamp, labels, - body, + BodyString(body), spans, nonEmptyItems, images.value, diff --git a/app/src/main/java/com/philkes/notallyx/presentation/widget/WidgetFactory.kt b/app/src/main/java/com/philkes/notallyx/presentation/widget/WidgetFactory.kt index fd108f77..45bde92a 100644 --- a/app/src/main/java/com/philkes/notallyx/presentation/widget/WidgetFactory.kt +++ b/app/src/main/java/com/philkes/notallyx/presentation/widget/WidgetFactory.kt @@ -76,7 +76,7 @@ class WidgetFactory( setTextViewTextSize(R.id.Note, TypedValue.COMPLEX_UNIT_SP, bodyTextSize) if (note.body.isNotEmpty()) { - setTextViewText(R.id.Note, note.body) + setTextViewText(R.id.Note, note.body.value) setViewVisibility(R.id.Note, View.VISIBLE) } else setViewVisibility(R.id.Note, View.GONE) diff --git a/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt index 6b16ccd6..524f0721 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/AndroidExtensions.kt @@ -388,7 +388,7 @@ fun Context.viewFile(uri: Uri, mimeType: String) { fun ContextWrapper.shareNote(note: BaseNote) { val body = when (note.type) { - Type.NOTE -> note.body + Type.NOTE -> note.body.value Type.LIST -> note.items.toMutableList().toText() } val filesUris = diff --git a/app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt b/app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt new file mode 100644 index 00000000..753ab3f3 --- /dev/null +++ b/app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt @@ -0,0 +1,89 @@ +package com.philkes.notallyx.utils + +import android.provider.ContactsContract.CommonDataKinds.StructuredName.PREFIX +import android.util.Base64 +import android.util.Log +import com.github.luben.zstd.Zstd +import com.philkes.notallyx.data.model.Converters +import com.philkes.notallyx.data.model.SpanRepresentation +import com.philkes.notallyx.utils.CompressUtility.COMPRESSION_THRESHOLD +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import org.json.JSONObject + +/** + * 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 BaseNote.body (String persisted in DB), we store GZIP(Base64) with a small prefix marker. + */ +object CompressUtility { + + // Threshold in characters for when to compress text (approximately 7KB) + const val COMPRESSION_THRESHOLD: Int = 7_000 + + // Prefix to mark a String as compressed (so we can store in a TEXT column). + private const val PREFIX: String = "GZ:" + + // region Text + Spans (ByteArray) + + /** Compresses text and spans using GZIP compression into a ByteArray. */ + fun compressTextAndSpans(text: String, spans: List): ByteArray { + val jsonObject = JSONObject() + jsonObject.put("text", text) + jsonObject.put("spans", Converters.spansToJSONArray(spans)) + val bytes = jsonObject.toString().toByteArray(Charsets.UTF_8) + return Zstd.compress(bytes, 4) + } + + /** Decompresses text and spans that were compressed with GZIP. */ + fun decompressTextAndSpans(compressedData: ByteArray): Pair> { + val decompressedSize = Zstd.decompressedSize(compressedData) + val result = ByteArray(decompressedSize.toInt()) + Zstd.decompress(result, compressedData) + 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) + val text = jsonObject.getString("text") + val spansArray = jsonObject.getJSONArray("spans") + val spans = Converters.jsonToSpans(spansArray) + return Pair(text, spans) + } + + // endregion + + // region String-only (BaseNote.body) + + /** Compress String if above threshold. Returns original if already small. */ + fun compressIfNeeded(text: String): String { + if (text.length <= COMPRESSION_THRESHOLD) return text + return try { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).use { it.write(text.toByteArray(Charsets.UTF_8)) } + val b64 = Base64.encodeToString(bos.toByteArray(), Base64.NO_WRAP) + PREFIX + b64 + } catch (e: Exception) { + Log.w("CompressUtility", "Failed to compress, returning original", e) + text + } + } + + /** Decompress String if it was previously compressed with [compressIfNeeded]. */ + fun decompressIfNeeded(text: String): String { + if (!text.startsWith(PREFIX)) return text + val b64 = text.removePrefix(PREFIX) + return try { + val bytes = Base64.decode(b64, Base64.NO_WRAP) + val bis = ByteArrayInputStream(bytes) + GZIPInputStream(bis).use { it.readBytes().toString(Charsets.UTF_8) } + } catch (e: Exception) { + Log.w("CompressUtility", "Failed to decompress, returning original", e) + text + } + } + // endregion +} diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt index 91a0d5e4..bbcf0c04 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/ImportExtensions.kt @@ -18,6 +18,7 @@ import com.philkes.notallyx.data.imports.ImportProgress import com.philkes.notallyx.data.imports.ImportStage import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.BaseNote +import com.philkes.notallyx.data.model.BodyString import com.philkes.notallyx.data.model.Converters import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.Folder @@ -319,7 +320,7 @@ private fun Cursor.toBaseNote(): BaseNote { timestamp, modifiedTimestamp, labels, - body, + BodyString(body), spans, items, images, diff --git a/app/src/main/java/com/philkes/notallyx/utils/backup/XmlParserExtensions.kt b/app/src/main/java/com/philkes/notallyx/utils/backup/XmlParserExtensions.kt index 573c4c99..519b5ea4 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/backup/XmlParserExtensions.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/backup/XmlParserExtensions.kt @@ -1,6 +1,7 @@ package com.philkes.notallyx.utils.backup import com.philkes.notallyx.data.model.BaseNote +import com.philkes.notallyx.data.model.BodyString import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.Label import com.philkes.notallyx.data.model.ListItem @@ -107,7 +108,7 @@ private fun XmlPullParser.parseBaseNote(rootTag: String, folder: Folder): BaseNo timestamp, timestamp, labels, - body, + BodyString(body), spans, items, emptyList(), diff --git a/app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt b/app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt index e73aedb5..c6ece35f 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/changehistory/ChangeHistory.kt @@ -84,3 +84,10 @@ class ChangeHistory { private const val TAG = "ChangeHistory" } } + +fun ChangeHistory.lastChangeAs(): T? { + if (canUndo.value) { + return lookUp() as? T + } + return null +} diff --git a/app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt b/app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt index dc9718e0..4a77d814 100644 --- a/app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt +++ b/app/src/main/java/com/philkes/notallyx/utils/changehistory/EditTextWithHistoryChange.kt @@ -1,10 +1,22 @@ package com.philkes.notallyx.utils.changehistory +import android.graphics.Typeface import android.text.Editable +import android.text.SpannableStringBuilder +import android.text.style.CharacterStyle +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.TypefaceSpan +import android.text.style.URLSpan +import android.util.Log import androidx.core.text.getSpans +import com.philkes.notallyx.data.model.SpanRepresentation +import com.philkes.notallyx.presentation.applySpans import com.philkes.notallyx.presentation.clone import com.philkes.notallyx.presentation.view.misc.StylableEditTextWithHistory import com.philkes.notallyx.presentation.view.misc.highlightableview.HighlightSpan +import com.philkes.notallyx.utils.CompressUtility +import kotlin.system.measureTimeMillis class EditTextWithHistoryChange( private val editText: StylableEditTextWithHistory, @@ -15,7 +27,7 @@ class EditTextWithHistoryChange( override fun update(value: EditTextState, isUndo: Boolean) { editText.applyWithoutTextWatcher { - val text = value.text.withoutSpans() + val text = value.getEditableText().withoutSpans() setText(text) updateModel.invoke(text) requestFocus() @@ -24,7 +36,106 @@ class EditTextWithHistoryChange( } } -data class EditTextState(val text: Editable, val cursorPos: Int) +/** + * Represents the state of an EditText, storing either the full text or a compressed version for + * large text to reduce memory usage. + */ +class EditTextState(text: Editable, val cursorPos: Int) { + companion object {} + + // Either Editable (for small text) or ByteArray (compressed, for large text and spans) + private val textContent: Any + + init { + // Extract spans from the Editable + // Compress text and spans together + this.textContent = + if (text.length > CompressUtility.COMPRESSION_THRESHOLD) { + Log.d("COMPRESS", "compressing text: ${text.take(10)}... (length: ${text.length})") + // Extract spans from the Editable + val spans = extractSpansFromEditable(text) + // Compress text and spans together + CompressUtility.compressTextAndSpans( + text.toString(), + spans as List, + ) + } else { + text + } + } + + /** Extracts spans from an Editable and converts them to SpanRepresentation objects. */ + private fun extractSpansFromEditable(text: Editable): List { + val representations = mutableListOf() + + text.getSpans(0, text.length, CharacterStyle::class.java).forEach { span -> + val end = text.getSpanEnd(span) + val start = text.getSpanStart(span) + + // Skip invalid spans + if (start < 0 || end < 0 || start >= text.length || end > text.length) { + return@forEach + } + + val representation = + SpanRepresentation( + start = start, + end = end, + bold = false, + link = false, + linkData = null, + italic = false, + monospace = false, + strikethrough = false, + ) + + when (span) { + is StyleSpan -> { + if (span.style == Typeface.BOLD) { + representation.bold = true + } else if (span.style == Typeface.ITALIC) { + representation.italic = true + } + } + is URLSpan -> { + representation.link = true + representation.linkData = span.url + } + is TypefaceSpan -> { + if (span.family == "monospace") { + representation.monospace = true + } + } + is StrikethroughSpan -> { + representation.strikethrough = true + } + } + + if (representation.isNotUseless()) { + representations.add(representation) + } + } + + return representations + } + + /** Returns the Editable text, decompressing it if necessary and applying spans. */ + fun getEditableText(): Editable { + return when (textContent) { + is Editable -> textContent + is ByteArray -> { + var pair: Pair>? + val time = measureTimeMillis { + pair = CompressUtility.decompressTextAndSpans(textContent) + } + Log.d("COMPRESS", "decompress took: $time ms") + val pairs = pair as Pair> + pairs.first.applySpans(pairs.second) + } + else -> SpannableStringBuilder() + } + } +} inline fun Editable.withoutSpans(): Editable = clone().apply { this.getSpans().forEach { removeSpan(it) } } diff --git a/app/src/test/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtilsTest.kt b/app/src/test/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtilsTest.kt index ca2c0a32..c9462129 100644 --- a/app/src/test/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtilsTest.kt +++ b/app/src/test/java/com/philkes/notallyx/data/imports/markdown/MarkdownUtilsTest.kt @@ -20,7 +20,7 @@ class MarkdownUtilsTest { timestamp = now, modifiedTimestamp = now, labels = emptyList(), - body = body, + body = BodyString(body), spans = spans, items = emptyList(), images = emptyList(), @@ -77,9 +77,11 @@ class MarkdownUtilsTest { val (body, spans) = parseBodyAndSpansFromMarkdown(input) val expectedBody = buildString { - append("Title\n") + append("# Title\n") append("This has bold, italic, code, strike, and a link.\n") - append("Also an image inline: Alt text and nested bolditalic.\n") + append( + "Also an image inline: ![Alt text](https://example.com/image.png) and nested bolditalic.\n" + ) append("Next line.") } assertEquals(expectedBody, body) diff --git a/app/src/test/java/com/philkes/notallyx/data/model/MarkdownExportTest.kt b/app/src/test/java/com/philkes/notallyx/data/model/MarkdownExportTest.kt index 99fb17e6..9faee0a7 100644 --- a/app/src/test/java/com/philkes/notallyx/data/model/MarkdownExportTest.kt +++ b/app/src/test/java/com/philkes/notallyx/data/model/MarkdownExportTest.kt @@ -17,7 +17,7 @@ class MarkdownExportTest { timestamp = now, modifiedTimestamp = now, labels = emptyList(), - body = body, + body = BodyString(body), spans = spans, items = emptyList(), images = emptyList(), @@ -109,7 +109,7 @@ class MarkdownExportTest { timestamp = now, modifiedTimestamp = now, labels = emptyList(), - body = "", + body = BodyString(""), spans = emptyList(), items = items, images = emptyList(), diff --git a/app/src/test/kotlin/com/philkes/notallyx/data/imports/google/GoogleKeepImporterTest.kt b/app/src/test/kotlin/com/philkes/notallyx/data/imports/google/GoogleKeepImporterTest.kt index 7ce9cd50..bbaf2080 100644 --- a/app/src/test/kotlin/com/philkes/notallyx/data/imports/google/GoogleKeepImporterTest.kt +++ b/app/src/test/kotlin/com/philkes/notallyx/data/imports/google/GoogleKeepImporterTest.kt @@ -2,6 +2,7 @@ package com.philkes.notallyx.data.imports.google import com.philkes.notallyx.data.model.Audio import com.philkes.notallyx.data.model.BaseNote +import com.philkes.notallyx.data.model.BodyString import com.philkes.notallyx.data.model.FileAttachment import com.philkes.notallyx.data.model.Folder import com.philkes.notallyx.data.model.ListItem @@ -246,7 +247,7 @@ class GoogleKeepImporterTest { timestamp, modifiedTimestamp, labels, - body, + BodyString(body), spans, items, images, diff --git a/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt b/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt index 1971ac6f..ba52d864 100644 --- a/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt +++ b/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt @@ -105,7 +105,7 @@ class ModelExtensionsTest { 12354632465L, 945869546L, listOf("label"), - "Body", + BodyString("Body"), listOf(SpanRepresentation(0, 10, bold = true)), mutableListOf( createListItem("Item1", true, false),