Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ fun parseBodyAndSpansFromMarkdown(input: String): Pair<String, List<SpanRepresen
spans.add(SpanRepresentation(start, end, strikethrough = true))
} else {
sb.append(renderer.render(customNode))
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
}
}

Expand Down Expand Up @@ -120,7 +119,6 @@ fun parseBodyAndSpansFromMarkdown(input: String): Pair<String, List<SpanRepresen

override fun visit(image: Image) {
sb.append(renderer.render(image))
if (sb.isNotEmpty() && sb.last() != '\n') sb.append('\n')
}
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.philkes.notallyx.data.imports.ExternalImporter
import com.philkes.notallyx.data.imports.ImportProgress
import com.philkes.notallyx.data.imports.markdown.parseBodyAndSpansFromMarkdown
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.ListItem
import com.philkes.notallyx.data.model.NoteViewMode
Expand Down Expand Up @@ -80,7 +81,7 @@ class PlainTextImporter : ExternalImporter {
timestamp = timestamp,
modifiedTimestamp = timestamp,
labels = listOf(),
body = if (listItems.isEmpty()) body else "",
body = BodyString(if (listItems.isEmpty()) body else ""),
spans = if (listItems.isEmpty()) spans else listOf(),
items = listItems,
images = listOf(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ data class BaseNote(
val timestamp: Long,
val modifiedTimestamp: Long,
val labels: List<String>,
val body: String,
val body: BodyString,
val spans: List<SpanRepresentation>,
val items: List<ListItem>,
val images: List<FileAttachment>,
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/philkes/notallyx/data/model/BodyString.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) = JSONArray(labels).toString()

@TypeConverter fun jsonToLabels(json: String) = jsonToLabels(JSONArray(json))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ fun String.toBaseNote(): BaseNote {
timestamp,
modifiedTimestamp,
labels,
body,
BodyString(body),
spans,
items,
images,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -232,7 +232,7 @@ fun List<BaseNote>.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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<EditTextWithHistoryChange>()?.newValue
?: EditTextState(getTextClone(), selectionStart)
Comment on lines +344 to +347
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Undo cursor position regressed

When the user moves the caret (no text change) and then types again, we now reuse the previous change’s newValue, so stateBefore keeps the old selection instead of the current selectionStart. Undoing the new edit jumps the cursor back to the prior position, breaking the expected undo behaviour.

Please rebuild stateBefore with the current selection while still reusing the cached text snapshot. For example:

-        stateBefore =
-            changeHistory.lastChangeAs<EditTextWithHistoryChange>()?.newValue
-                ?: EditTextState(getTextClone(), selectionStart)
+        val previous = changeHistory.lastChangeAs<EditTextWithHistoryChange>()?.newValue
+        stateBefore =
+            previous?.let { EditTextState(it.getEditableText(), selectionStart) }
+                ?: EditTextState(getTextClone(), selectionStart)

This keeps the clone optimisation but ensures undo restores the caret to the position the user actually had before the change.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/src/main/java/com/philkes/notallyx/presentation/UiExtensions.kt around
lines 344-347, the current code reuses the previous change’s EditTextState
wholesale so stateBefore retains the old selection and causes undo to jump the
caret to a prior position; instead, build stateBefore by reusing the cached text
snapshot from lastChangeAs<EditTextWithHistoryChange>()?.newValue but replace
its selection with the current selectionStart (i.e., construct a new
EditTextState using the last change’s text / clone and the current
selectionStart), and fall back to EditTextState(getTextClone(), selectionStart)
when there is no cached change.

}

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -346,7 +349,7 @@ class NotallyModel(private val app: Application) : AndroidViewModel(app) {
timestamp,
modifiedTimestamp,
labels,
body,
BodyString(body),
spans,
nonEmptyItems,
images.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
89 changes: 89 additions & 0 deletions app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.philkes.notallyx.utils

import android.provider.ContactsContract.CommonDataKinds.StructuredName.PREFIX
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove unused import.

Line 3 imports PREFIX from ContactsContract.CommonDataKinds.StructuredName, but this constant is never used. The file defines its own PREFIX constant on line 27.

Apply this diff:

-import android.provider.ContactsContract.CommonDataKinds.StructuredName.PREFIX
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import android.provider.ContactsContract.CommonDataKinds.StructuredName.PREFIX
🤖 Prompt for AI Agents
In app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt around line
3, remove the unused import "import
android.provider.ContactsContract.CommonDataKinds.StructuredName.PREFIX" because
the file defines its own PREFIX constant at line 27; simply delete that import
line so there are no conflicting or unused imports left.

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.
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Correct the misleading comment.

The comment states "GZIP-compressed JSON as ByteArray" but the implementation uses Zstd compression (line 37), not GZIP.

Apply this diff:

- * - 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.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* - 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.
🤖 Prompt for AI Agents
In app/src/main/java/com/philkes/notallyx/utils/CompressUtility.kt around line
18, the comment incorrectly says "GZIP-compressed JSON as ByteArray" while the
implementation uses Zstd compression; update the comment to accurately reflect
the compression type (e.g., "Zstd-compressed JSON as ByteArray") and adjust any
nearby comments or docs in this file that reference GZIP to Zstd to keep the
source consistent.

* - 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<SpanRepresentation>): 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<String, List<SpanRepresentation>> {
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
}
Loading