Skip to content

Editor performance, a data-loss fix, and fixes from a full-app review - #184

Merged
pasichDev merged 58 commits into
masterfrom
perf/editors
Nov 13, 2026
Merged

pasichDev merged 58 commits into
masterfrom
perf/editors

Conversation

@pasichDev

@pasichDev pasichDev commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Profiling of both editors on a Pixel 7a, then a review of the whole app (sync, data/backup, UI, editors/security). Every finding below was checked against the code and has a test where practical. Commits are grouped by area; review commit by commit.

Also brings in e4b10fe (third billing review round), which was pushed to #183 after it was merged.

Editors

  • Data loss: the simple editor's maxLength="20000" cut longer notes on open and the first edit saved the cut text. Removed; a long draft now goes to a retained ViewModel instead of the saved-state bundle (no TransactionTooLargeException).
  • Simple editor typing: EmojiCompat reported a whole-text change after every keystroke, so the whole note was laid out again. 109 KB note: 104% → 13% of a core.
  • Extended editor typing: plain-text notes opened as one Editor.js paragraph; now a block per line up to 200 lines, ~1 KB blocks beyond. 109 KB note renderer: 49% → 17.5%, opening 2.50 s → 1.96 s CPU. Undo keeps each state once, as JSON; a word's undo step closes after a 300 ms pause instead of saving the whole document on every space.
  • Security: plain text was put into the page unescaped (shared <img onerror> ran script with window.Android); now escaped. File access and mixed content off in the WebView. Block id/URL quoted in evaluated JS. Shared VIEW URIs: only other apps' content://.
  • No lost edits: Back/Up, switching editors and rotation flush the page and the presenter first. The content batching tried earlier in this PR is removed (it widened the window and measured no gain).
  • Plain ↔ rich round trip decodes HTML entities, headers included.

Sync

  • Old tombstones are no longer published forever (the 10,000-record limit would stop sync for good); rule documented on SyncService.forPublication.
  • A save during snapshot building is no longer reverted by the apply; the stale-edit guard compares with the timestamp the record had when built.
  • Typing into a note another device deleted no longer revives its tombstone.
  • Attachments verified once per sync through the digest cache; one Drive listing per blob.
  • Settled conflicts trimmed oldest-first; "Sync now" can no longer undo a disconnect.

Backup, import, files

  • Local backup save/restore off the main thread, streamed (no whole-file buffers), restore in one transaction and not cancelled by a rotation.
  • Import and backup-options dialogs survive recreation; import can be cancelled.
  • Photos: subsampled to ≤4096 px (no OOM on 48–108 MP), EXIF orientation applied.
  • Encly: clearing deletes only what was handed over, and only when nothing was skipped.
  • Google Keep checklists import as [ ]/[x] lines. PDF export breaks pages per drawn line and breaks overlong words.

UI

  • Tag dialogs survive rotation/process death (no-arg constructors + arguments).
  • Deleted tasks stop notifying; task reminder receiver uses goAsync and the 24 h grace.
  • Share/export runs off the main thread and never writes an empty file after recreation.
  • Dialog writes (pin, trash, tags, copy) are no longer cancelled by the dialog closing; "Delete tag and notes" shows when the count arrives.
  • Main list reads notes once per change, sorts off the main thread, pauses search/stats while hidden. Tasks list uses DiffUtil and writes a reorder in one transaction.
  • Reminder pickers arm the alarm independently of the sheet.

Verification

./gradlew spotlessCheck test lint assembleRelease and npm test (24) pass locally. Not exercised on a device: the activity-level flows (Back/switch/rotate in the editors, backup restore, dialog recreation).

…acters

The note body had android:maxLength="20000". A length filter also applies to
setText, so a longer note (shared into the app, written in the extended
editor, restored from a backup or synced) opened cut short, and the first
edit autosaved the cut text over the note: on a device, deleting one
character from a 109 KB note left 20,000 characters in the database.

Input is no longer limited; SimpleEditorLengthTest pins a 109,000-character
note.
…eaner undo

Measured on a Pixel 7a with a 109 KB note, typing in the extended editor
cost the WebView renderer 48% of a core; a 1 KB note cost 12%.

- A plain-text note (from the simple editor, shared into the app, synced)
  opened as ONE paragraph with a <br> per line, so every keystroke made the
  browser lay out the whole note again. Each line of text is now a
  paragraph, as if typed with Enter; blank lines go in front of the next
  line as <br>. Joined back with newlines the blocks give exactly the text
  that came in (save.test.mjs, PlainTextBlocksRoundTripTest).
- The document went to Android on every Editor.js change: a full save,
  sanitize and stringify of the note and a copy over the bridge. It now
  goes once typing pauses (700 ms), at least every 3 s while typing goes
  on, and at once through flushContent when the screen stops. Android only
  saves after two quiet seconds anyway.
- Undo states kept every document twice, parsed and as JSON, for up to
  100 states. They keep the JSON only and parse it when a step is applied.
A block per line made a 1,300-line note take 70 MB more in the WebView
renderer (121 MB instead of 50): every Editor.js block holds DOM and a
mutation observer of its own. Notes up to 200 lines of text keep a block
per line; longer ones are cut into blocks of about 1,000 characters, only
between two lines that hold text, so joined back they give exactly the same
text and no block starts or ends with a blank line.

Pixel 7a, 109 KB / 1,300-line note, mean of three runs, WebView renderer:
typing 49.0% -> 17.5% of a core, opening 2.50 s -> 1.96 s of CPU,
memory 49.6 MB -> 58.3 MB.
…eystroke

The note body is an AppCompatEditText, so its text is an emoji2
SpannableBuilder. After every edit EmojiCompat processes the change and
ends its batch with a text-changed report for the WHOLE text, and
DynamicLayout then lays out the whole note again. A simpleperf profile
of typing put 70% of the main thread in DynamicLayout.reflow.

Pixel 7a, typing in the simple editor, app process:
  109 KB note: 104% -> 13% of a core
   27 KB note:  44% -> 10% of a core

EmojiCompat is turned off for the note body (app:emojiCompatEnabled);
the system emoji font draws emoji without it. SimpleEditorEmojiCompatTest
pins the setting.
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Test coverage (unit + instrumentation)

Overall Project 18.73% -5.46% 🍏
Files changed 18.79% 🍏

Module Coverage
debug 21.27% -4.59% 🍏
debug 16.18% -6.32% 🍏
Files
Module File Coverage
debug NoteDeletedException.java 100% 🍏
RetainedNoteDraft.java 93.9% -6.1% 🍏
DeleteTagDialogPresenter.java 90.12% 🍏
GoogleDriveSyncBackend.java 90.06% 🍏
SyncStore.java 88.57% 🍏
SyncService.java 87.99% -1.81% 🍏
ExportedSnapshotStore.java 87.62% -12.38% 🍏
NameTagDialogPresenter.java 85.29% 🍏
GoogleKeepNote.java 82.5% -1.88% 🍏
SyncCoordinator.java 80.74% -1.05% 🍏
NotePresenter.java 77.78% -0.33% 🍏
ZipBackupHelper.java 71.48% -5.32% 🍏
SyncMutationCoordinator.java 65.88% -0.19% 🍏
MainPresenter.java 56.84% -1.2% 🍏
MoreNoteDialogPresenter.java 54.62% -17.31% 🍏
DetachedWrites.java 45.83% -54.17% 🍏
BackupPresenter.java 45.35% -17.97% 🍏
TasksPresenter.java 41.86% -2.01% 🍏
EnclyMigrationRepository.java 40.85% -36.88% 🍏
FileExportUtils.java 27.81% -21.18% 🍏
EditorJsonUtils.java 6.21% -22.24% 🍏
BillingManager.java 3.69% -58.29% 🍏
BackupActivity.java 0% -14.29% 🍏
MeetEnclyActivity.java 0% -15.37% 🍏
SupportActivity.java 0% -1.95% 🍏
MainActivity.java 0% -0.98% 🍏
ShareActivity.java 0% -8.29% 🍏
TagsActivity.java 0% -17.69% 🍏
TaskReminderReceiver.java 0% -50% 🍏
ShareOptionsDialog.java 0% -87.21% 🍏
MoreNoteDialog.java 0% -3.87% 🍏
OtherAppImportDialog.java 0% -77.68% 🍏
BackupOptionsDialog.java 0% -48.51% 🍏
TaskReminderPickerBottomSheet.java 0% -16.6% 🍏
ReminderPickerBottomSheet.java 0% -11.39% 🍏
EditorJSInterface.java 0% -14.82% 🍏
AppDataManager.java 0% -2.16% 🍏
TasksAdapter.java 0% -29.03% 🍏
ThankedPurchases.java 0% -46.74% 🍏
BackupCacheHelper.java 0% 🍏
AppDbHelper.java 0% -7.78% 🍏
ImageOptimizer.java 0% -49.29% 🍏
MediaSettingsFragment.java 0% -18.58% 🍏
ShareProcessor.java 0% -68.18% 🍏
NoteActivity.java 0% -4.82% 🍏
BaseNoteEditorActivity.java 0% -8.72% 🍏
NoteExtendedEditorActivity.java 0% -13.27% 🍏
RoomSyncStore.java 0% -5.47% 🍏
EditableLinkMovementMethod.java 0% -1.15% 🍏
DeleteTagDialog.java 0% -56.6% 🍏
NameTagDialog.java 0% -24.56% 🍏
TagOptionsBottomSheet.java 0% -48.87% 🍏
TagArgs.java 0% 🍏
ReminderRescheduler.java 0% -23.69% 🍏
LocalBackup.java 0% -70% 🍏
NoteEditorView.java 0% -4.31% 🍏
debug RoomSyncStore.java 81.93% -0.64% 🍏
SyncService.java 61.33% -7.78% 🍏
ReminderRescheduler.java 42.17% -18.47% 🍏
BackupActivity.java 0% -14.29% 🍏
MeetEnclyActivity.java 0% -15.37% 🍏
SupportActivity.java 0% -1.95% 🍏
MainActivity.java 0% -0.98% 🍏
ShareActivity.java 0% -8.29% 🍏
TagsActivity.java 0% -17.69% 🍏
TaskReminderReceiver.java 0% -50% 🍏
ShareOptionsDialog.java 0% -87.21% 🍏
MoreNoteDialog.java 0% -3.87% 🍏
OtherAppImportDialog.java 0% -77.68% 🍏
BackupOptionsDialog.java 0% -48.51% 🍏
TaskReminderPickerBottomSheet.java 0% -16.6% 🍏
ReminderPickerBottomSheet.java 0% -11.39% 🍏
EditorJsonUtils.java 0% -22.93% 🍏
EditorJSInterface.java 0% -14.82% 🍏
AppDataManager.java 0% -2.16% 🍏
TasksAdapter.java 0% -29.03% 🍏
ThankedPurchases.java 0% -46.74% 🍏
BillingManager.java 0% -58.29% 🍏
BackupCacheHelper.java 0% 🍏
NoteDeletedException.java 0% 🍏
AppDbHelper.java 0% -7.78% 🍏
FileExportUtils.java 0% -48.02% 🍏
ImageOptimizer.java 0% -49.29% 🍏
MainPresenter.java 0% -13.5% 🍏
BackupPresenter.java 0% -50.74% 🍏
TasksPresenter.java 0% -19.01% 🍏
NotePresenter.java 0% -8.81% 🍏
MediaSettingsFragment.java 0% -18.58% 🍏
ShareProcessor.java 0% -68.18% 🍏
NoteActivity.java 0% -4.82% 🍏
BaseNoteEditorActivity.java 0% -8.72% 🍏
NoteExtendedEditorActivity.java 0% -13.27% 🍏
GoogleKeepNote.java 0% -64.38% 🍏
GoogleDriveSyncBackend.java 0% -1.33% 🍏
SyncMutationCoordinator.java 0% -2.79% 🍏
SyncStore.java 0% -48.57% 🍏
NameTagDialogPresenter.java 0% -61.76% 🍏
MoreNoteDialogPresenter.java 0% -42.69% 🍏
DetachedWrites.java 0% 🍏
DeleteTagDialogPresenter.java 0% -50.62% 🍏
ExportedSnapshotStore.java 0% 🍏
EnclyMigrationRepository.java 0% -75.8% 🍏
RetainedNoteDraft.java 0% 🍏
EditableLinkMovementMethod.java 0% -1.15% 🍏
DeleteTagDialog.java 0% -56.6% 🍏
NameTagDialog.java 0% -24.56% 🍏
TagOptionsBottomSheet.java 0% -48.87% 🍏
TagArgs.java 0% 🍏
SyncCoordinator.java 0% -17.16% 🍏
ZipBackupHelper.java 0% -33.46% 🍏
LocalBackup.java 0% -70% 🍏
NoteEditorView.java 0% -4.31% 🍏

… path, client ended on destroy

- Play Billing answers are handled on the main thread, so the batch counter
  and the record of thanked purchases are never touched from two threads.
- One path consumes leftover purchases (at start, before a purchase, after
  ITEM_ALREADY_OWNED). Whether a purchase was thanked is read when its
  consume finishes, so a purchase the purchase flow thanks meanwhile is not
  thanked twice, and none is consumed without its thanks.
- SupportActivity ends the billing client in onDestroy: after a rotation the
  old client kept the activity alive and handled purchase updates for it.
- A failed product query reports its real response code (billing
  unavailable, no network) instead of "Product not found".
- Tokens of purchases that are gone (refunded, revoked) leave the record;
  checking a token no longer copies the whole set.
- EditableLinkMovementMethod resets the meta state when it swallows the
  platform's selection crash, so Shift does not stay held.
- The notification volume slider stops retrying for the rest of a drag once
  Do Not Disturb refuses the change, and snaps back when the drag ends.
Keep exports a checklist note's items in listContent and leaves textContent out, so every checklist arrived as an empty note. The items are now written the way the app turns a checklist into plain text, [ ] and [x] per line.
The batched send (700 ms idle, 3 s max wait) widened the window in which a
Back press or a screen change could act on an older document, and measured
no benefit. flushContent and finishPage keep working as before.
syncNow switched sync back on from the worker thread right before running
it, so the isSyncEnabled check SyncService makes under its lock could never
see a disconnect that landed while the sync was queued. Enable it on the
calling thread instead, where disconnect also runs.
… alive

DeleteTagDialog, NameTagDialog and TagOptionsBottomSheet only had
argument constructors, so the system could not recreate them after a
rotation or process death. They now take their tag via arguments and
the options sheet finds its listener on the host.

The delete/rename/create writes no longer belong to the dialog's
disposables, which dismiss() cleared before the io thread ran them.
The 'delete tag and notes' row now appears when the note count
arrives, and a failing count no longer crashes.
updateNoteContent touched the sync metadata even when the content UPDATE
matched no row because a sync had just applied another device's deletion.
That cleared deletedAt, leaving a live record with no note behind it and an
updatedAt that outranked the remote tombstone forever. Check for the row in
the same transaction, write nothing when it is gone, and return false.
…turning them sideways

A 48-108 MP photo was decoded at full size as ARGB_8888, 190-430 MB, and the OutOfMemoryError was not caught. The bounds are read first and the photo is subsampled to at most 4096 px on its long side; running out of memory keeps the original bytes.

Re-encoding dropped the EXIF block, and with it the Orientation tag, so portrait photos were stored sideways. The orientation is now applied to the pixels before compressing.
plainTextBlocks put each line into a paragraph unescaped, and the paragraph
tool renders it with innerHTML, so a shared note such as
<img src=x onerror=...> ran script in a page that can call Android.
Escape &, < and > per line.

The editor page also no longer needs file access or mixed content: the page
comes from file:///android_asset (not governed by setAllowFileAccess) and
attachments from the editorjs:// interceptor.
cleanText only decoded &nbsp;, so text like "a < b && c" came back from the
extended editor as "a &lt; b &amp;&amp; c", and header text was not cleaned
at all. Decode entities in one pass for paragraphs, headers and list items.
The sheet's buttons start a write and dismiss in the same click, and
dismissing disposed the presenter's subscriptions and dropped the
activity, so pin, trash and tag changes could be cancelled before they
ran and Copy never opened the copy (its callback also ran off the main
thread). The writes now run on their own, and Copy delivers the new id
to the activity on the main thread.
The query feeding the cap on published settled versions was a plain UNION,
so the cap kept whatever order SQLite's de-duplication left and could drop
the newest settlements, which were then offered again. Order it by
resolution time, interleave local and remote settlements under the cap, and
delete settled rows beyond the number that can ever be published.
The page break was checked once per source line, so a paragraph that wrapped to many lines ran off the bottom of the page, and a word wider than the line was never drawn at all. The break is now checked per drawn line and an overlong word is broken between characters. The PDF, TXT and HTML streams are closed when a write fails.
…e spot

Every space or Enter closed the history batch at once, which saves and
serializes the whole document: on a long note, on every finished word.
A finished word or line now closes its batch after 300 ms; a brief pause
still makes it its own step, and Editor.js's late change report does not
push the close back.
Deleting a task left its alarm armed, and the receiver posted from the
alarm's extras and re-armed repeating reminders, so a deleted task kept
notifying every N minutes. Deleting now cancels the alarm, and the
receiver loads the task and stops when it is gone, done or has no
reminder, re-arming one that sync moved later.

The receiver now writes the database inside goAsync() before finishing,
so a killed process cannot leave a fired one-time reminder stored as
pending, and re-arming after boot skips one-time task reminders missed
by more than a day, as for notes.
deleteAttachmentBlockRequest pasted the block id into evaluated JavaScript
as-is and escaped the URL only for single quotes. Pass both as JSON string
literals.
…reading it

The exported VIEW handler read any URI: a missing one threw on a bare thread
and crashed the app, and a file:// URI or one of the app's own providers
copied its private files into a note. Refuse those, and report any failure
while reading instead of crashing.
BackupOptionsDialog only had a constructor taking its callback, so restoring it after a rotation or process death threw InstantiationException. It now keeps its mode in its arguments and asks the hosting activity for the callback when the user chooses.

The gathered backup is kept in a process-wide cache, so saving to Drive or to a file picked after a rotation still finds it; when the process was recreated the save reports a failure instead of crashing.
…apply

The apply skips a record that moved on locally after the snapshot build
read it, but two things defeated that guard:

- buildSnapshot read a note's content and only then re-read its updatedAt,
  so an edit saved in between paired the old content with the new
  timestamp; the guard let the apply through and the edit was reverted on
  every device. The timestamp is now read right before the content.
- the guard compared the local updatedAt with the merged record's, which is
  another device's clock whenever the remote version wins. A remote clock
  ahead let a local edit be overwritten; one behind made an untouched record
  refuse every newer remote version. It now compares with the updatedAt the
  build saw for that record.
PDF and HTML exports were built and written on the main thread in the
file picker's result, which for a data export of every note could hang
the app; the TXT export ran in the background but used the dialog's
context after it was dismissed, and toasted off the main thread. All
three now build and write on the io thread with the application context
and report the outcome on the main thread.

The notes lived only in fields, so a dialog recreated while the picker
was open wrote an empty '***' file and reported success. The note ids
and flags are now arguments, the notes are read back when needed, and
an export with nothing left to write reports an error.
For every attachment hash, every sync listed the Drive root for it, read and
SHA-256'd the whole local file (bypassing AttachmentHashCache), then listed
the root again to verify the remote copy.

- SyncStore gains hasVerifiedAttachment; RoomSyncStore answers it from the
  hash cache the snapshot build already uses, so an unchanged blob is not
  re-hashed. A full read still produces the precise error when the local
  copy is bad and there is no remote one to repair it from.
- GoogleDriveSyncBackend remembers each candidate listing for the sync and
  drops it for any root and hash it uploads to, so existence and
  verification share one listing.
…back

The reminder pickers wrote the reminder first and armed the alarm in a
main-thread callback that the sheet's onDestroyView cancelled, which
could leave a stored reminder with no alarm; dismiss() after
onSaveInstanceState also threw. The alarm is now armed (or cancelled)
right after the write on the io thread, outside the sheet's
disposables, and the sheet closes with dismissAllowingStateLoss() if it
is still there.
Saving read every attachment whole into memory to zip it, then read the finished archive whole again to copy it to the picked document. Restoring copied the whole file into a byte array only to look at its first two bytes. Attachments, the JSON and the copy to the document are now streamed in chunks, the archive is sniffed from a stream, and the ZIP stream is closed and the temporary archive removed even when a write fails.
… a rotation

Restoring read and unpacked the backup on the main thread after a 3 s timer, and saving built and copied the archive on the main thread from the activity-result callback; both could freeze the screen into an ANR on a large library. Both now run on the io scheduler.

A restore wrote notes, tags and trash as three transactions tied to the screen's disposables, so a rotation part-way through left notes restored and tags or trash not, without a word. They are now one transaction, followed by the preferences, and neither a save nor a restore is cancelled by the screen going away: they report to the screen that is current when they end, or to the next one.

The Google Keep import dialog could not be cancelled while scanning, and after a rotation the restored dialog spun forever with Back blocked, while a late result hit a null field. It now keeps the file in its arguments, can always be cancelled, and the recreated screen scans again for it.
Every tombstone went into every bundle forever, and the schema caps a
bundle at 10,000 records, live and deleted together. A library that had
seen enough deletions failed every publish from then on, permanently.

A tombstone is now published while its deletion is under 90 days old, and
after that only when this sync read a live version of the record from Drive
or an open conflict alternative for it is carried along. The rows stay in
sync_metadata and are still merged and applied locally, so a device that
slept through the horizon and republishes a stale copy gets the tombstone
back from the next device that syncs, and the merge deletes the copy as it
always would have. The rule is documented on SyncService.forPublication.
NoteActivity put the whole unsaved title and body into the saved-state
bundle. With the 20,000-character limit gone, a long note could crash the
app with TransactionTooLargeException. Above 64 K characters the draft now
stays in memory (RetainedNoteDraft) for the recreated screen instead, as
the extended editor does for its document.
…k all of it

Clear My Notes deleted whatever the database held when the button was pressed, so a note written or synced in after the hand-off archive was made was deleted although it never reached Encly. The ids the archive carried are now remembered (and kept in the saved state, since the process can be killed while Encly is in front) and only those records and their attachment folders are deleted.

The clear was also offered on any successful result, even when Encly reported skipped records that exist nowhere but here; it is now offered only when nothing was skipped.
… hidden

The list and the search each subscribed to the full notes query, so
every change (each editor autosave included) read every note body
twice; the query is now shared. Choosing a tag or a sort order ran the
filter and sort of every note on the main thread; the combiner now runs
on the computation scheduler. Search and the drawer statistics stop
while the main screen is hidden and catch up when it is back. The list
itself stays live so it already shows the edit when the editor's return
transition starts.
# Conflicts:
#	app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java
…kes content:// only

BackupActivity used the deprecated ShareOptionsDialog constructor. ShareActivity's VIEW filter still advertised file:// URIs, which ShareProcessor now refuses.
@pasichDev pasichDev changed the title fix(editor): long notes no longer cut to 20,000 chars; faster typing in both editors Editor performance, a data-loss fix, and fixes from a full-app review Nov 13, 2026
A declined or failed authorization used to leave sync switched on. The
flag is now set from the authorization callback, and only when no
disconnect happened since Sync now was tapped (tracked by a process-wide
disconnect generation), so a disconnect is still never undone.
…unning

Picking a backup or importing from Keep while another restore ran was
dropped silently behind the running restore's progress dialog. It now
shows a message (new string restore_already_running, all locales).
A long unsaved draft was put into RetainedNoteDraft on every
onSaveInstanceState, also when the screen only went to the background.
It stayed there, and the next rotation applied it over newer saved
content, which autosave then wrote back. The draft is now kept only
while the screen is changing configurations, a save of state without a
recreation forgets any draft left over, a document the old page hands
over after it has finished is ignored, and the handed-over document is
kept with the title held at that moment.
updateNote completed successfully when the note row had been deleted
meanwhile (a sync applying another device's delete), so the editor
showed Saved and every later edit was silently discarded. updateNote
now fails with NoteDeletedException when nothing was written, and the
note presenter saves the content as a new note and carries on editing
that one; a lock keeps two overlapping saves from creating it twice.
…at started it

A result that found no attached screen waited in a static for the life
of the process and was shown on any later visit to the Backup screen,
snackbar and theme reapply included. It is now kept for the starting
screen only, identified by a token kept in its saved state, and dropped
when that screen is finished rather than recreated.
…was sent

retainOnly pruned the thanked record against a queryPurchases answer
that can predate a purchase thanked meanwhile; after a failed consume
that purchase came back and was thanked a second time. Only tokens
recorded when the query was sent are now judged by its answer.
buildSnapshot re-read every metadata row with its own point query after
getAll(), thousands of SELECTs per sync. Rows are now re-read 500 at a
time with an IN query per record type, still right before that batch's
content is read, so a timestamp never postdates the content it pairs
with.
clearExported deleted the carried notes, tasks, tags and task lists by
id only, so a record edited after the archive was written, here or by
a background sync, was deleted with content Encly never received, and
its tombstone synced everywhere. The hand-off now records a content
fingerprint per record, kept across process death with the ids, and
the clear deletes only records whose content still matches; changed
ones stay and are counted in the log.
… upload

The listing was dropped before the upload, after it, and again in the
catch. Nothing can cache it again in between (the upload never lists and
callers hold the backend lock), so the single drop up front is enough.
…refused

The document picker creates the target file before the app is called back,
so refusing a save while another one runs left a 0-byte "backup" behind
without a word. Delete it (best effort) and say a save is already running.
An older app version keeps re-adding expired tombstones to every bundle it
writes. Compared with those, the pruned snapshot never matched, so both
devices republished the whole bundle on every sync. Compare against the
remote as this version would publish it instead.
…hase

hasVerifiedAttachment flushed the whole hash cache after every attachment,
rewriting it N times on a cold cache. The store now flushes once when the
sync's attachment phase ends, through a new finishAttachmentVerification
hook.
syncNow switched sync on before handing the work to the screen's executor.
When the executor had already shut down the sync never ran, yet the flag
stayed on. Restore the previous value when the submission is refused; the
refusal is reported to the caller as before.
Saving the open note as a new row left the screen's intent, saved state
and draft on the deleted id, so a rotation reopened a blank editor, and
its attachments still pointed into note_<oldId>. The note is now put
back under the same id with the editor's content, and its sync metadata
is touched past the tombstone so the edit wins on the next sync.
Keyboard, D-pad and TalkBack changes never start or stop tracking, so a
refusal under Do Not Disturb left the slider at a volume not in effect
and latched every later change as refused.
An id and a fingerprint for every record went into the saved instance
state, which a large library could push past the Binder limit. The
snapshot is written to no-backup storage and only its token is saved.
@pasichDev
pasichDev merged commit 58b3c59 into master Nov 13, 2026
4 checks passed
@pasichDev pasichDev mentioned this pull request Nov 13, 2026
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