Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
c87e042
fix(editor): stop the simple editor cutting long notes to 20,000 char…
pasichDev Nov 13, 2026
d7cb407
perf(editor): a block per line for plain-text notes, batched sends, l…
pasichDev Nov 13, 2026
3d72fe7
perf(editor): cut very long plain-text notes into kilobyte blocks
pasichDev Nov 13, 2026
b370d10
perf(editor): stop EmojiCompat relaying out the whole note on every k…
pasichDev Nov 13, 2026
126f2a7
Merge remote-tracking branch 'origin/master' into perf/editors
pasichDev Nov 13, 2026
e4b10fe
fix(billing): third review round — main-thread callbacks, one consume…
pasichDev Nov 13, 2026
e8c709b
fix(import): bring Google Keep checklists over with their items
pasichDev Nov 13, 2026
f3527ac
fix(editor): send each change to Android at once again
pasichDev Nov 13, 2026
67d5a03
fix(sync): stop a queued manual sync from undoing a disconnect
pasichDev Nov 13, 2026
0e36214
fix(tags): rebuild tag dialogs after recreation and keep their writes…
pasichDev Nov 13, 2026
fe7f03d
fix(sync): don't revive the tombstone of a note deleted under the editor
pasichDev Nov 13, 2026
1692470
fix(attachments): attach huge and rotated photos without crashing or …
pasichDev Nov 13, 2026
3bb6c20
fix(editor): show markup in a plain-text note as text, not as HTML
pasichDev Nov 13, 2026
44fc8fc
fix(editor): decode HTML entities when a rich note becomes plain text
pasichDev Nov 13, 2026
0054a26
fix(notes): let the note sheet's writes and Copy outlive the sheet
pasichDev Nov 13, 2026
ba36e4b
fix(sync): trim the oldest settled conflicts, not arbitrary ones
pasichDev Nov 13, 2026
dcfa021
fix(export): keep long PDF paragraphs and overlong words on the page
pasichDev Nov 13, 2026
ba1bf7f
perf(editor): close a word's undo step after a short pause, not on th…
pasichDev Nov 13, 2026
491cc61
fix(tasks): stop reminders of deleted or completed tasks
pasichDev Nov 13, 2026
d32d04b
fix(editor): quote the block id and file URL in the delete-block script
pasichDev Nov 13, 2026
e9052c7
fix(share): open only other apps' content as a note, and never crash …
pasichDev Nov 13, 2026
9ee0940
fix(backup): let the backup destination sheet survive a rotation
pasichDev Nov 13, 2026
50f1d6e
fix(sync): keep edits saved during a sync from being reverted by its …
pasichDev Nov 13, 2026
4c8d6c7
fix(share): export off the main thread and survive recreation
pasichDev Nov 13, 2026
21e4f7d
perf(sync): verify attachments once per sync, through the digest cache
pasichDev Nov 13, 2026
aa5cabb
fix(reminders): arm the alarm with the write, not in the sheet's call…
pasichDev Nov 13, 2026
c0de9e4
perf(backup): stream backup archives instead of holding them in memory
pasichDev Nov 13, 2026
13aacce
fix(backup): save, restore and import off the main thread and through…
pasichDev Nov 13, 2026
96bc400
fix(sync): stop publishing deletions every device has long seen
pasichDev Nov 13, 2026
6fe1774
fix(editor): hand the editor's last edits over before closing, switch…
pasichDev Nov 13, 2026
cc17f1f
perf(tasks): rebind only changed rows and write a reorder in one go
pasichDev Nov 13, 2026
a792a3c
fix(editor): keep a long simple-editor draft out of the saved state
pasichDev Nov 13, 2026
ef17db0
fix(encly): clear only what was handed to Encly, and only when it too…
pasichDev Nov 13, 2026
f65f4bb
Merge branch 'worktree-agent-a373fb8786db85ad7' into perf/editors
pasichDev Nov 13, 2026
cc290dd
Merge branch 'worktree-agent-aa022410fbb77dc22' into perf/editors
pasichDev Nov 13, 2026
4745744
perf(main): read the notes once, sort off the main thread, rest while…
pasichDev Nov 13, 2026
9140708
Merge branch 'worktree-agent-a396fa47e15951c3c' into perf/editors
pasichDev Nov 13, 2026
29578c7
fix(share): data export opens its dialog through newInstance; VIEW ta…
pasichDev Nov 13, 2026
dbb27cc
fix(sync): enable sync only after Drive authorization succeeds
pasichDev Nov 13, 2026
6bd4eb6
fix(backup): tell the user when a restore is refused because one is r…
pasichDev Nov 13, 2026
60d9013
fix(editor): keep a long draft in memory only for a recreation
pasichDev Nov 13, 2026
dc96b5f
fix(notes): save edits to a note deleted while open as a new note
pasichDev Nov 13, 2026
27fe909
fix(backup): deliver a late restore/save result only to the screen th…
pasichDev Nov 13, 2026
56b57ba
fix(billing): keep thanked tokens recorded after the purchases query …
pasichDev Nov 13, 2026
4614daf
perf(sync): re-read snapshot metadata in batches instead of per record
pasichDev Nov 13, 2026
7b3a82b
fix(encly): keep records changed since the hand-off when clearing
pasichDev Nov 13, 2026
feb064c
refactor(sync): invalidate an attachment's candidate listing once per…
pasichDev Nov 13, 2026
d5c85ea
refactor(share): drop the unused deprecated ShareOptionsDialog constr…
pasichDev Nov 13, 2026
2a61269
Merge branch 'worktree-agent-a7ad7f364e45396c7' into perf/editors
pasichDev Nov 13, 2026
5ac5dd8
fix(backup): remove the empty file and explain when a second save is …
pasichDev Nov 13, 2026
1a5311e
fix(sync): ignore expired tombstones when deciding whether to publish
pasichDev Nov 13, 2026
c422531
perf(sync): persist the attachment digest cache once per attachment p…
pasichDev Nov 13, 2026
e04aee9
fix(sync): leave sync off when a gone screen refuses the manual sync
pasichDev Nov 13, 2026
70be951
fix(editor): revive a note deleted while open under its own id
pasichDev Nov 13, 2026
47d0e39
fix(settings): revert a refused non-touch volume change at once
pasichDev Nov 13, 2026
0092454
fix(encly): keep the hand-off snapshot on disk, not in saved state
pasichDev Nov 13, 2026
e1059e1
fix(export): keep a wrapped line's indent in the PDF export
pasichDev Nov 13, 2026
cd7ca2c
Merge branch 'worktree-agent-abc9091a1ad29085c' into perf/editors
pasichDev Nov 13, 2026
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: 0 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@
<category android:name="android.intent.category.DEFAULT" />

<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/save_to_notes">
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/assets/editor/js/runtime.min.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions app/src/main/java/com/pasich/mynotes/data/AppDataManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ public Completable updateNote(Note note) {
return dbHelper.updateNote(note);
}

@Override
public Completable reviveNote(Note note) {
return dbHelper.reviveNote(note);
}

@Override
public Completable moveToNotes(List<Note> notes) {
return dbHelper.moveToNotes(notes);
Expand Down Expand Up @@ -402,6 +407,11 @@ public Completable updateTask(Task task) {
return dbHelper.updateTask(task);
}

@Override
public Completable updateTasks(List<Task> tasks) {
return dbHelper.updateTasks(tasks);
}

@Override
public Completable deleteTask(Task task) {
return dbHelper.deleteTask(task);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,17 @@ public Completable deleteNote(ArrayList<Note> notes) {

@Override
public Completable updateNote(Note note) {
return Completable.fromAction(() -> syncMutationCoordinator.updateNoteContent(note));
return Completable.fromAction(() -> {
// A note deleted meanwhile must not read as saved: every later edit would be lost.
if (!syncMutationCoordinator.updateNoteContent(note)) {
throw new NoteDeletedException(note.getId());
}
});
}

@Override
public Completable reviveNote(Note note) {
return Completable.fromAction(() -> syncMutationCoordinator.reviveNote(note));
}

@Override
Expand Down Expand Up @@ -306,6 +316,15 @@ public Completable updateTask(Task task) {
return Completable.fromAction(() -> syncMutationCoordinator.updateTask(task));
}

@Override
public Completable updateTasks(List<Task> tasks) {
// One transaction, so the task lists are reloaded once rather than once per task.
return Completable.fromAction(() ->
appDatabase.runInTransaction(() -> {
for (Task task : tasks) syncMutationCoordinator.updateTask(task);
}));
}

@Override
public Completable deleteTask(Task task) {
return Completable.fromAction(() -> syncMutationCoordinator.deleteTask(task));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.pasich.mynotes.data.database;

/**
* A note's content could not be written because the note no longer exists, for instance because a
* sync applied another device's delete while it was open. Nothing was written.
*/
public class NoteDeletedException extends RuntimeException {

public NoteDeletedException(long noteId) {
super("Note " + noteId + " no longer exists");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,21 @@ public interface SyncConflictDao {
@Query("SELECT COUNT(*) FROM sync_conflicts WHERE resolved = 0")
int getUnresolvedCount();

/** Both sides of every settled conflict; neither version may be offered again. */
/**
* Both sides of every settled conflict, most recently settled first; neither version may be
* offered again.
*
* <p>The order matters: the bundle carries only a bounded number of these, and the plain UNION
* this used to be returned them in whatever order SQLite's de-duplication left them, so the cap
* dropped arbitrary — often the newest — settlements and offered them again.
*/
@Query(
"SELECT winnerVersionId FROM sync_conflicts WHERE resolved = 1 AND winnerVersionId != ''"
+ " UNION SELECT loserVersionId FROM sync_conflicts WHERE resolved = 1 AND"
+ " loserVersionId != ''")
"SELECT versionId FROM ("
+ "SELECT winnerVersionId AS versionId, resolvedAt, id FROM sync_conflicts"
+ " WHERE resolved = 1 AND winnerVersionId != ''"
+ " UNION ALL SELECT loserVersionId AS versionId, resolvedAt, id"
+ " FROM sync_conflicts WHERE resolved = 1 AND loserVersionId != '')"
+ " GROUP BY versionId ORDER BY MAX(resolvedAt) DESC, MAX(id) DESC")
List<String> getResolvedVersionIds();

@Query("SELECT * FROM sync_conflicts WHERE id = :id LIMIT 1")
Expand All @@ -51,6 +61,18 @@ public interface SyncConflictDao {
+ "AND stableId = :stableId AND winnerVersionId != :winnerVersionId")
void deleteSupersededUnresolved(String recordType, String stableId, String winnerVersionId);

/**
* Deletes every settled conflict but the {@code keep} most recently settled ones.
*
* <p>Settled rows only exist to stop a version being offered again, and only the newest ones
* are ever published; without this they accumulated for the life of the install.
*/
@Query(
"DELETE FROM sync_conflicts WHERE resolved = 1 AND id NOT IN ("
+ "SELECT id FROM sync_conflicts WHERE resolved = 1"
+ " ORDER BY resolvedAt DESC, id DESC LIMIT :keep)")
int deleteResolvedBeyond(int keep);

@Query(
"UPDATE sync_conflicts SET resolution = :resolution, resolved = 1, resolvedAt = :resolvedAt WHERE id = :id")
void markResolved(long id, String resolution, long resolvedAt);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public interface SyncMetadataDao {
+ "WHERE recordType = :recordType AND localId IN (:localIds)")
List<Long> getExistingLocalIds(String recordType, List<Long> localIds);

/** The rows of {@code localIds} that exist; the caller chunks the list. */
@Query(
"SELECT * FROM sync_metadata "
+ "WHERE recordType = :recordType AND localId IN (:localIds)")
List<SyncMetadataEntity> getByLocalIds(String recordType, List<Long> localIds);

@Query(
"SELECT * FROM sync_metadata WHERE recordType = :recordType AND stableId = :stableId LIMIT 1")
SyncMetadataEntity getByStableId(String recordType, String stableId);
Expand All @@ -64,7 +70,13 @@ public interface SyncMetadataDao {
+ "WHERE recordType = :recordType AND localId = :localId")
void touch(String recordType, long localId, long nowMillis);

/** Retains the metadata row and records a monotonic tombstone timestamp. */
/**
* Retains the metadata row and records a monotonic tombstone timestamp.
*
* <p>Tombstone rows are never deleted. Old ones stop being published (see {@code
* SyncService.forPublication}), and the row kept here is what lets this device answer a stale
* live copy republished by a device that never saw the deletion.
*/
@Query(
"UPDATE sync_metadata SET "
+ "updatedAt = CASE WHEN updatedAt >= :nowMillis THEN updatedAt + 1 ELSE :nowMillis END, "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public interface DbNotesHelper {

Completable updateNote(Note note);

/**
* Stores a note's content, putting the note back under its own id when it was deleted
* meanwhile.
*/
Completable reviveNote(Note note);

Completable moveToNotes(List<Note> notes);

Completable setTagNote(String nameTag, int idNote);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public interface DbTasksHelper {

Completable updateTask(Task task);

/** Updates several tasks in one transaction, e.g. their positions after a drag. */
Completable updateTasks(List<Task> tasks);

Completable deleteTask(Task task);

Completable toggleTask(int taskId, boolean isDone);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ public final class GoogleDriveSyncBackend implements SyncBackend {
*/
private final Map<String, Long> verifiedAttachments = new HashMap<>();

/**
* Candidate listings already made during this sync, keyed by root and hash.
*
* <p>The service asks whether a blob exists and then whether it is verified, and each question
* listed the root again: two Drive listings per attachment per sync, for every attachment in
* the library. The answer the first one got is the one the second needs. Anything this backend
* uploads drops the listing for its root and hash, so a fresh copy is always seen.
*/
private final Map<String, List<AttachmentCandidate>> candidateListings = new HashMap<>();

public GoogleDriveSyncBackend(@NonNull String accessToken) {
this(accessToken, DEFAULT_API, DEFAULT_UPLOAD, Clock.systemUTC(), new SyncBundleCodec());
}
Expand Down Expand Up @@ -667,6 +677,11 @@ private AttachmentCandidate(
@NonNull
private List<AttachmentCandidate> listAttachmentCandidates(
@NonNull String folderId, @NonNull String sha256) throws IOException {
String listingKey = memoKey(folderId, sha256);
List<AttachmentCandidate> listed = candidateListings.get(listingKey);
if (listed != null) {
return listed;
}
JsonArray files =
listFiles(
ownedFilesQuery(folderId, PROPERTY_ATTACHMENT_SHA256, sha256),
Expand All @@ -684,7 +699,9 @@ private List<AttachmentCandidate> listAttachmentCandidates(
// same hash are byte-identical and either one will do. Rejecting them used to break every
// subsequent sync permanently.
candidates.sort(Comparator.comparing(candidate -> candidate.id));
return candidates;
List<AttachmentCandidate> result = Collections.unmodifiableList(candidates);
candidateListings.put(listingKey, result);
return result;
}

/**
Expand Down Expand Up @@ -1148,6 +1165,11 @@ private void uploadAttachmentOrConfirm(
if (sizeBytes < 0L) {
throw new IllegalArgumentException("An attachment upload needs a declared size");
}
// Whatever happens next, the remembered listing no longer describes this root. It is
// dropped once, up front: the upload itself never lists, and every caller holds this
// backend's lock, so nothing can remember a listing again before the upload ends. The
// discovery below lists afresh, and what it remembers includes what the upload created.
candidateListings.remove(memoKey(folderId, sha256));
try {
uploadStream(folderId, sha256, MIME_BINARY, content, sizeBytes);
} catch (IOException uploadFailure) {
Expand Down
Loading
Loading