From c87e042da284d86a8fce564553fc904313a1fe02 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 13:54:25 +0200 Subject: [PATCH 01/52] fix(editor): stop the simple editor cutting long notes to 20,000 characters 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. --- app/src/main/res/layout/activity_note.xml | 1 - app/src/main/res/values/integers.xml | 1 - .../noteEditor/SimpleEditorLengthTest.java | 39 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java diff --git a/app/src/main/res/layout/activity_note.xml b/app/src/main/res/layout/activity_note.xml index 95afa3ac..dd307208 100644 --- a/app/src/main/res/layout/activity_note.xml +++ b/app/src/main/res/layout/activity_note.xml @@ -232,7 +232,6 @@ android:hint="@string/hintTextNote" android:inputType="textAutoComplete|text|textCapSentences|textMultiLine" android:longClickable="true" - android:maxLength="@integer/maxLengthNotesTextValue" android:minHeight="100dp" android:padding="20dp" android:singleLine="false" diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index dbe45ab7..6690e89f 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -2,6 +2,5 @@ 3 100 - 20000 10 \ No newline at end of file diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java new file mode 100644 index 00000000..223ae8fe --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java @@ -0,0 +1,39 @@ +package com.pasich.mynotes.ui.view.activity.noteEditor; + +import static com.google.common.truth.Truth.assertThat; + +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; +import com.pasich.mynotes.R; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SimpleEditorLengthTest { + + /** + * A note longer than any input limit (shared into the app, written in the extended editor, or + * restored from a backup) must reach the simple editor whole. A length filter also cuts the + * text passed to setText, and the next autosave then wrote the cut text over the note. + */ + @Test + public void aLongNoteIsShownWhole() { + View root = + LayoutInflater.from( + new ContextThemeWrapper( + RuntimeEnvironment.getApplication(), R.style.DefaultTheme)) + .inflate(R.layout.activity_note, null); + EditText body = root.findViewById(R.id.valueNote); + String note = "x".repeat(109_000); + + body.setText(note); + + assertThat(body.getText().length()).isEqualTo(note.length()); + } +} From d7cb40728d919840c19177a7e5cf586bf7fcf134 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 14:11:29 +0200 Subject: [PATCH 02/52] perf(editor): a block per line for plain-text notes, batched sends, leaner 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
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
. 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. --- app/src/main/assets/editor/js/runtime.min.js | 2 +- .../utils/PlainTextBlocksRoundTripTest.java | 42 +++++++ notes_editor/src/custom/runtime.js | 99 +++++++++++---- notes_editor/test/save.test.mjs | 118 ++++++++++++++++++ 4 files changed, 234 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java create mode 100644 notes_editor/test/save.test.mjs diff --git a/app/src/main/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js index 8af8f0c0..422fdc2c 100644 --- a/app/src/main/assets/editor/js/runtime.min.js +++ b/app/src/main/assets/editor/js/runtime.min.js @@ -2,4 +2,4 @@ * My Notes — Editor Runtime Build * (c) 2025 pasichDev. Editor.js — MIT */ -!function(){"use strict";let e=null,t=!1,n=null;function safeAndroidCall(e,...t){if(window.Android&&"function"==typeof window.Android[e])try{return window.Android[e](...t)}catch(t){console.error(`[AndroidBridge] ${e} failed:`,t)}return null}window.attachEditorInstance=function(n){e=n,window.__EDITOR_READY=!0,t=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){e&&e.save().then(e=>{const t=e.blocks,o=JSON.stringify(t);o!==n&&(n=o,safeAndroidCall("onContentChanged",o))}).catch(e=>console.error("[Editor] Save failed:",e))}function flushContent(){return e?e.save().then(e=>{const t=JSON.stringify(e.blocks);t!==n&&(n=t,safeAndroidCall("onContentFlushed",t))}).catch(e=>console.error("[Editor] Flush failed:",e)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!e)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const t=e.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(t){const n=e.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/t):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const t=e.blocks.getBlocksCount();for(let o=0;o0){n.topId=t.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){e&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let c=0;function reportViewStateNow(){clearTimeout(i),c=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-c>=250&&(c=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(e,t){const n=document.createTreeWalker(e,NodeFilter.SHOW_TEXT);let o=Math.max(0,t||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(e,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!e||!n)return;if(n.caretId&&!t){const t=e.blocks.getById(n.caretId);if(t?.holder){try{e.caret.setToBlock(t,"start")}catch(e){console.error("[Editor] setToBlock failed:",e)}const o=editableInputs(t.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?e.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const t=e.blocks.getBlocksCount();t>0&&(o=e.blocks.getBlockByIndex(Math.min(n.topIndex,t-1)))}o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(n.topOffset||0))})}function keepCaretVisible(){const e=window.getSelection();if(!e||0===e.rangeCount)return;const t=e.focusNode,n=t&&(1===t.nodeType?t:t.parentElement);if(!n||!n.isContentEditable)return;const o=e.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const s=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!e||t)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const e=document.createRange();return e.selectNodeContents(o),e.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:e.toString().length}}return caretPosition()}function readDocument(){return e.save().then(e=>({title:historyTitleElement()?.innerText||"",blocks:e.blocks}))}function historyEntry(e,t){return{doc:e,json:JSON.stringify(e),caret:t}}function reportHistory(){const e=a.undo.length>0||a.pending,t=a.redo.length>0&&!a.pending,n=`${e}|${t}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",e,t))}function attachmentUrls(e){const t=new Set;for(const n of e?.blocks||[]){const e=n?.data;if(e?.file?.url&&t.add(e.file.url),Array.isArray(e?.files))for(const n of e.files)n?.url&&t.add(n.url)}return t}function keepAttachmentsForUndo(e,t){const n=attachmentUrls(t),o=[...attachmentUrls(e)].filter(e=>!n.has(e));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(e){return{json:e.json,caret:e.caret||null}}function importedEntry(e){return historyEntry(JSON.parse(e.json),e.caret||null)}function resetHistory(n){if(stopHistory(),!e||t)return;const o=a.epoch;readDocument().then(e=>{o===a.epoch&&(a.current=historyEntry(e,null),n&&(a.settleUntil=Date.now()+600),function(){const e=u;if(u=null,e?.current&&e.current.json===a.current?.json)try{a.undo=(e.undo||[]).map(importedEntry).slice(-s),a.redo=(e.redo||[]).map(importedEntry).slice(-s),a.current.caret=e.current.caret||null}catch(e){console.error("[History] import failed:",e),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(e=>console.error("[History] reset failed:",e))}function historyNoteChange(e,n){if(!a.current||a.applying||t)return;if(n&&Date.now()=3e3;a.timer=setTimeout(captureHistory,e||o?0:700),reportHistory()}function historyInputStarting(){!a.current||a.applying||t||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(t){if(clearTimeout(a.timer),a.timer=null,!e||!a.current||!a.pending&&!t)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(e=>{if(n!==a.epoch||!a.current)return;const t=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(e,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(a.current.doc,e),a.undo.push({...a.current,caret:t||a.current.caret}),a.undo.length>s&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(t){if(!t)return null;const n=e.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,t.caret),a.settleUntil=Date.now()+600,function(t,n,o){const r=historyTitleElement();if(t?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,t.offset);else if(t?.id&&findBlock(t.id))restoreViewState({caretId:t.id,caretInput:t.input,caretOffset:t.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const t=findBlock(o);if(t)try{e.caret.setToBlock(t,"end")}catch(e){console.error("[History] placing the caret failed:",e)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(t.caret,c,s),saveContent())}).catch(e=>console.error("[History] applying a step failed:",e)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(e){return a.queue=a.queue.then(e).catch(e=>console.error("[History]",e)),a.queue}function historyUndo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=a.undo.pop();if(e&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(e)}))}function historyRedo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=a.redo.pop();if(e&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(e)}))}document.addEventListener("keydown",e=>{const t=function(e){if(!e.ctrlKey&&!e.metaKey||e.altKey)return null;const t=(e.key||"").toLowerCase();return"z"===t?e.shiftKey?"redo":"undo":"y"!==t||e.shiftKey?null:"redo"}(e);if(t)return e.preventDefault(),e.stopPropagation(),void("undo"===t?historyUndo():historyRedo());(function(e){if(e.ctrlKey||e.metaKey||e.altKey)return!1;if(229===e.keyCode)return!0;const t=e.key||"";return 1===t.length||"Enter"===t||"Backspace"===t||"Delete"===t||"Tab"===t})(e)&&(historyInputStarting(),"Enter"===e.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",e=>{if("historyUndo"===e.inputType||"historyRedo"===e.inputType)return e.preventDefault(),void("historyUndo"===e.inputType?historyUndo():historyRedo());historyInputStarting();const t="string"==typeof e.data?e.data:"";historyNoteChange("insertParagraph"===e.inputType||"insertLineBreak"===e.inputType||/\s$/.test(t)&&(a.batchHasText||/\S/.test(t)),!1),/\S/.test(t)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});const f=new Map;let p=0;function requestUpload(e,t){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,e,t.name||"",t.size||0)&&function(e){return new Promise((t,n)=>{const o=new FileReader;o.onload=()=>t(o.result),o.onerror=n,o.readAsDataURL(e)})}(t).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,e,n,t.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(e,t){const n=f.get(e);n&&(f.delete(e),n(t))}function updateTitlePlaceholder(){const e=document.getElementById("noteTitleInput");e.innerText.trim()?e.removeAttribute("data-placeholder"):e.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(t,n){if(e)try{const o=e.blocks.getById(t);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;e.blocks.delete(i),function(e){const strip=t=>{const n=t.doc.blocks.filter(t=>t.id!==e);return n.length===t.doc.blocks.length?t:historyEntry({title:t.doc.title,blocks:n},t.caret)},distinct=e=>e.filter((t,n)=>0===n||t.json!==e[n-1].json);a.current&&(a.current=strip(a.current));const t=distinct(a.undo.map(strip));a.current&&t.length&&t[t.length-1].json===a.current.json&&t.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=t,a.redo=n.reverse(),reportHistory()}(t),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",t,n)},30)}catch(e){console.error("[Delete] JS ERROR:",e)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const e=document.getElementById("noteTitleInput");e&&(e.setAttribute("contenteditable",t?"false":"true"),t&&document.activeElement===e&&e.blur())}function setReadMode(n,o){if(!e?.readOnly)return Promise.resolve(t);const r=currentViewState();return t=n,applyTitleEditable(),e.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!t||!e)return;const r=function(e,t){const n=document.caretRangeFromPoint?.(e,t);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(e=>e.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(e){if(!e)return;const t=document.documentElement;for(const n in e)t.style.setProperty(`--${n}`,e[n])},window.loadNote=function(o){if(!e)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=[{type:"paragraph",data:{text:o.plainText.replace(/\n/g,"
")}}]:o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,e.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(t){if(!e||!t||t.index<0)return;const n=e.blocks.getBlocksCount();if(0===n)return;const o=e.blocks.getBlockByIndex(Math.min(t.index,n-1));o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(t.offset||0))})}(o.anchor),o.focusStart&&!t&&!o.viewState?.caretId){const t=o.viewState;let n=t?.topId?e.blocks.getById(t.topId):null;if(!n&&t?.topIndex>=0){const o=e.blocks.getBlocksCount();o>0&&(n=e.blocks.getBlockByIndex(Math.min(t.topIndex,o-1)))}try{n?e.caret.setToBlock(n,"start"):e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(e){const t=await requestUpload("file",e);return t&&t.success&&t.file?{success:1,file:{url:t.file.url,name:e.name,size:e.size,extension:e.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!t,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.currentBlockIndex=function(){return e?e.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(t,n,o){if(!e||!n||!n.url)return;const r=e.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===t?e.blocks.insert("image",{file:n},void 0,i,!1):e.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(e){return(e?runHistoryAction(()=>(t?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const e=a.undo.map(exportedEntry),t=a.redo.map(exportedEntry),size=e=>e.reduce((e,t)=>e+t.json.length,0);let n=a.current.json.length+size(e)+size(t);for(;n>d&&(e.length||t.length);)n-=(e.length>=t.length?e.shift():t.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:e,redo:t}))})):Promise.resolve()).catch(e=>console.error("[History] export failed:",e)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return e&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!e||t)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(e){historyNoteChange((Array.isArray(e)?e:[e]).some(e=>e&&e.type&&"block-changed"!==e.type),!0)},window.uploadImage=async function(e){const t=await requestUpload("image",e);return t&&t.success&&t.file?{success:1,file:t.file}:{success:0}}}(); +!function(){"use strict";let e=null,t=!1,n=null;function safeAndroidCall(e,...t){if(window.Android&&"function"==typeof window.Android[e])try{return window.Android[e](...t)}catch(t){console.error(`[AndroidBridge] ${e} failed:`,t)}return null}window.attachEditorInstance=function(n){e=n,window.__EDITOR_READY=!0,t=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");let r=null,i=0;function saveContent(){if(!e)return;const t=Date.now();r||(i=t),clearTimeout(r);r=setTimeout(sendContent,t-i>=3e3?0:700)}function cancelPendingSave(){clearTimeout(r),r=null}function sendContent(){return cancelPendingSave(),e?e.save().then(e=>{const t=JSON.stringify(e.blocks);t!==n&&(n=t,safeAndroidCall("onContentChanged",t))}).catch(e=>console.error("[Editor] Save failed:",e)):Promise.resolve()}function flushContent(){return cancelPendingSave(),e?e.save().then(e=>{const t=JSON.stringify(e.blocks);t!==n&&(n=t,safeAndroidCall("onContentFlushed",t))}).catch(e=>console.error("[Editor] Flush failed:",e)):Promise.resolve()}let l=null;function reportViewportAnchor(){if(!e)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const t=e.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(l),l=setTimeout(reportViewportAnchor,200)},{passive:!0});let c=null,s=!1;function blockForHolder(t){const n=e.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/t):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const t=e.blocks.getBlocksCount();for(let o=0;o0){n.topId=t.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){e&&s&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let a=0;function reportViewStateNow(){clearTimeout(c),a=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(c),Date.now()-a>=250&&(a=Date.now(),reportViewState()),c=setTimeout(reportViewStateNow,200)}function placeCaret(e,t){const n=document.createTreeWalker(e,NodeFilter.SHOW_TEXT);let o=Math.max(0,t||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(e,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!e||!n)return;if(n.caretId&&!t){const t=e.blocks.getById(n.caretId);if(t?.holder){try{e.caret.setToBlock(t,"start")}catch(e){console.error("[Editor] setToBlock failed:",e)}const o=editableInputs(t.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?e.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const t=e.blocks.getBlocksCount();t>0&&(o=e.blocks.getBlockByIndex(Math.min(n.topIndex,t-1)))}o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(n.topOffset||0))})}function keepCaretVisible(){const e=window.getSelection();if(!e||0===e.rangeCount)return;const t=e.focusNode,n=t&&(1===t.nodeType?t:t.parentElement);if(!n||!n.isContentEditable)return;const o=e.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const d=100,u={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!e||t)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const e=document.createRange();return e.selectNodeContents(o),e.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:e.toString().length}}return caretPosition()}function readDocument(){return e.save().then(e=>({title:historyTitleElement()?.innerText||"",blocks:e.blocks}))}function historyEntry(e,t){return{json:JSON.stringify(e),caret:t}}function historyDoc(e){return JSON.parse(e.json)}function reportHistory(){const e=u.undo.length>0||u.pending,t=u.redo.length>0&&!u.pending,n=`${e}|${t}`;n!==u.reported&&(u.reported=n,safeAndroidCall("onHistoryChanged",e,t))}function attachmentUrls(e){const t=new Set;for(const n of e?.blocks||[]){const e=n?.data;if(e?.file?.url&&t.add(e.file.url),Array.isArray(e?.files))for(const n of e.files)n?.url&&t.add(n.url)}return t}function keepAttachmentsForUndo(e,t){const n=attachmentUrls(t),o=[...attachmentUrls(e)].filter(e=>!n.has(e));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){u.epoch++,clearTimeout(u.timer),u.timer=null,u.undo=[],u.redo=[],u.current=null,u.pending=!1,u.caretBefore=null,u.lastCaret=null,u.applying=!1,u.settleUntil=0,reportHistory()}const f=15e5;let p=null;function exportedEntry(e){return{json:e.json,caret:e.caret||null}}function importedEntry(e){return historyEntry(JSON.parse(e.json),e.caret||null)}function resetHistory(n){if(stopHistory(),!e||t)return;const o=u.epoch;readDocument().then(e=>{o===u.epoch&&(u.current=historyEntry(e,null),n&&(u.settleUntil=Date.now()+600),function(){const e=p;if(p=null,e?.current&&e.current.json===u.current?.json)try{u.undo=(e.undo||[]).map(importedEntry).slice(-d),u.redo=(e.redo||[]).map(importedEntry).slice(-d),u.current.caret=e.current.caret||null}catch(e){console.error("[History] import failed:",e),u.undo=[],u.redo=[]}}(),reportHistory())}).catch(e=>console.error("[History] reset failed:",e))}function historyNoteChange(e,n){if(!u.current||u.applying||t)return;if(n&&Date.now()=3e3;u.timer=setTimeout(captureHistory,e||o?0:700),reportHistory()}function historyInputStarting(){!u.current||u.applying||t||u.pending||u.caretBefore||(u.caretBefore=historyCaret())}function captureHistory(t){if(clearTimeout(u.timer),u.timer=null,!e||!u.current||!u.pending&&!t)return Promise.resolve();const n=u.epoch,o=u.changeSeq;return readDocument().then(e=>{if(n!==u.epoch||!u.current)return;const t=u.caretBefore,r=o!==u.changeSeq;u.pending=r,u.caretBefore=null,r?u.batchStart=Date.now():u.batchHasText=!1;const i=historyEntry(e,historyCaret());i.json!==u.current.json&&(keepAttachmentsForUndo(historyDoc(u.current),e),u.undo.push({...u.current,caret:t||u.current.caret}),u.undo.length>d&&u.undo.shift(),u.redo=[],u.current=i),reportHistory()})}function findBlock(t){if(!t)return null;const n=e.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===u.epoch&&(u.current=historyEntry(n,t.caret),u.settleUntil=Date.now()+600,function(t,n,o){const r=historyTitleElement();if(t?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,t.offset);else if(t?.id&&findBlock(t.id))restoreViewState({caretId:t.id,caretInput:t.input,caretOffset:t.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const t=findBlock(o);if(t)try{e.caret.setToBlock(t,"end")}catch(e){console.error("[History] placing the caret failed:",e)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(t.caret,c,s),saveContent())}).catch(e=>console.error("[History] applying a step failed:",e)).finally(()=>{r===u.epoch&&(u.applying=!1),reportHistory()})}function runHistoryAction(e){return u.queue=u.queue.then(e).catch(e=>console.error("[History]",e)),u.queue}function historyUndo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=u.undo.pop();if(e&&u.current)return u.redo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(e)}))}function historyRedo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=u.redo.pop();if(e&&u.current)return u.undo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(e)}))}function plainTextBlocks(e){const t=[];let n="";for(const o of e.split("\n"))""!==o.trim()?(t.push({type:"paragraph",data:{text:n+o}}),n=""):t.length&&(n+=o+"
");return t}document.addEventListener("keydown",e=>{const t=function(e){if(!e.ctrlKey&&!e.metaKey||e.altKey)return null;const t=(e.key||"").toLowerCase();return"z"===t?e.shiftKey?"redo":"undo":"y"!==t||e.shiftKey?null:"redo"}(e);if(t)return e.preventDefault(),e.stopPropagation(),void("undo"===t?historyUndo():historyRedo());(function(e){if(e.ctrlKey||e.metaKey||e.altKey)return!1;if(229===e.keyCode)return!0;const t=e.key||"";return 1===t.length||"Enter"===t||"Backspace"===t||"Delete"===t||"Tab"===t})(e)&&(historyInputStarting(),"Enter"===e.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",e=>{if("historyUndo"===e.inputType||"historyRedo"===e.inputType)return e.preventDefault(),void("historyUndo"===e.inputType?historyUndo():historyRedo());historyInputStarting();const t="string"==typeof e.data?e.data:"";historyNoteChange("insertParagraph"===e.inputType||"insertLineBreak"===e.inputType||/\s$/.test(t)&&(u.batchHasText||/\S/.test(t)),!1),/\S/.test(t)&&(u.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{u.pending||u.applying||(u.lastCaret=historyCaret())});const h=new Map;let y=0;function requestUpload(e,t){return new Promise(n=>{const o=`u${Date.now()}_${++y}`;h.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,e,t.name||"",t.size||0)&&function(e){return new Promise((t,n)=>{const o=new FileReader;o.onload=()=>t(o.result),o.onerror=n,o.readAsDataURL(e)})}(t).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,e,n,t.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(e,t){const n=h.get(e);n&&(h.delete(e),n(t))}function updateTitlePlaceholder(){const e=document.getElementById("noteTitleInput");e.innerText.trim()?e.removeAttribute("data-placeholder"):e.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(t,n){if(e)try{const o=e.blocks.getById(t);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;e.blocks.delete(i),function(e){const strip=t=>{const n=historyDoc(t),o=n.blocks.filter(t=>t.id!==e);return o.length===n.blocks.length?t:historyEntry({title:n.title,blocks:o},t.caret)},distinct=e=>e.filter((t,n)=>0===n||t.json!==e[n-1].json);u.current&&(u.current=strip(u.current));const t=distinct(u.undo.map(strip));u.current&&t.length&&t[t.length-1].json===u.current.json&&t.pop();const n=distinct(u.redo.map(strip).reverse());u.current&&n.length&&n[0].json===u.current.json&&n.shift(),u.undo=t,u.redo=n.reverse(),reportHistory()}(t),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",t,n)},30)}catch(e){console.error("[Delete] JS ERROR:",e)}};const g=document.getElementById("noteTitleInput");function applyTitleEditable(){const e=document.getElementById("noteTitleInput");e&&(e.setAttribute("contenteditable",t?"false":"true"),t&&document.activeElement===e&&e.blur())}function setReadMode(n,o){if(!e?.readOnly)return Promise.resolve(t);const r=currentViewState();return t=n,applyTitleEditable(),e.readOnly.toggle(n).then(()=>(n||u.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}g.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",g.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!t||!e)return;const r=function(e,t){const n=document.caretRangeFromPoint?.(e,t);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(e=>e.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(e){if(!e)return;const t=document.documentElement;for(const n in e)t.style.setProperty(`--${n}`,e[n])},window.loadNote=function(o){if(!e)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),s=!1,stopHistory(),p=o.history||null,e.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(t){if(!e||!t||t.index<0)return;const n=e.blocks.getBlocksCount();if(0===n)return;const o=e.blocks.getBlockByIndex(Math.min(t.index,n-1));o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(t.offset||0))})}(o.anchor),o.focusStart&&!t&&!o.viewState?.caretId){const t=o.viewState;let n=t?.topId?e.blocks.getById(t.topId):null;if(!n&&t?.topIndex>=0){const o=e.blocks.getBlocksCount();o>0&&(n=e.blocks.getBlockByIndex(Math.min(t.topIndex,o-1)))}try{n?e.caret.setToBlock(n,"start"):e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{s=!0,reportViewState()}))})},window.uploadAttachment=async function(e){const t=await requestUpload("file",e);return t&&t.success&&t.file?{success:1,file:{url:t.file.url,name:e.name,size:e.size,extension:e.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!t,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return e?e.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(t,n,o){if(!e||!n||!n.url)return;const r=e.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===t?e.blocks.insert("image",{file:n},void 0,i,!1):e.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(e){return(e?runHistoryAction(()=>(t?Promise.resolve():captureHistory(!0)).then(()=>{if(!u.current)return;const e=u.undo.map(exportedEntry),t=u.redo.map(exportedEntry),size=e=>e.reduce((e,t)=>e+t.json.length,0);let n=u.current.json.length+size(e)+size(t);for(;n>f&&(e.length||t.length);)n-=(e.length>=t.length?e.shift():t.shift()).json.length;n>f||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(u.current),undo:e,redo:t}))})):Promise.resolve()).catch(e=>console.error("[History] export failed:",e)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return e&&s?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!e||t)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(e){historyNoteChange((Array.isArray(e)?e:[e]).some(e=>e&&e.type&&"block-changed"!==e.type),!0)},window.uploadImage=async function(e){const t=await requestUpload("image",e);return t&&t.success&&t.file?{success:1,file:t.file}:{success:0}}}(); diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java new file mode 100644 index 00000000..722a8d2c --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java @@ -0,0 +1,42 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import static com.google.common.truth.Truth.assertThat; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * The extended editor opens a plain-text note with a paragraph block per line, blank lines going in + * front of the next line as {@code
} (plainTextBlocks in notes_editor/src/custom/runtime.js). + * Turned back into plain text, those blocks must give exactly the note that went in, so switching + * editors never changes a note. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class PlainTextBlocksRoundTripTest { + + private static JSONObject paragraph(String html) throws Exception { + return new JSONObject() + .put("type", "paragraph") + .put("data", new JSONObject().put("text", html)); + } + + @Test + public void aBlockPerLineGivesBackTheSameText() throws Exception { + String text = "first line\n\nsecond paragraph\nthird line\n \n\nlast"; + JSONArray blocks = + new JSONArray() + .put(paragraph("first line")) + .put(paragraph("
second paragraph")) + .put(paragraph("third line")) + .put(paragraph("

last")); + + String plain = EditorJsonUtils.extendedNoteToOldNote(blocks.toString()).plainText; + + assertThat(plain).isEqualTo(text); + } +} diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index 33490977..d8142af4 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -38,27 +38,43 @@ const __pageParams = new URLSearchParams(window.location.search) const __doubleTapToEdit = __pageParams.get('dbltap') === '1' /** - * Save current blocks and send to Android. + * Sending the document is batched. Every send serializes the whole note (Editor.js saves and + * sanitizes every block, then it is stringified and copied to Android), so on a long note sending + * on each Editor.js change made typing cost grow with the note. Android saves only after it has + * been quiet for two seconds anyway; it gets the document once typing pauses, at least every few + * seconds while typing goes on, and at once through flushContent when the screen stops. */ +const SAVE_IDLE_MS = 700 +const SAVE_MAX_WAIT_MS = 3000 +let __saveTimer = null +let __saveFirstRequest = 0 +/** A change was made: the document goes to Android once typing pauses. */ function saveContent () { if (!editor) return + const now = Date.now() + if (!__saveTimer) __saveFirstRequest = now + clearTimeout(__saveTimer) + const overdue = now - __saveFirstRequest >= SAVE_MAX_WAIT_MS + __saveTimer = setTimeout(sendContent, overdue ? 0 : SAVE_IDLE_MS) +} - editor - .save() - .then(output => { - const blocks = output.blocks - const jsonStr = JSON.stringify(blocks) +function cancelPendingSave () { + clearTimeout(__saveTimer) + __saveTimer = null +} - // Якщо нічого не змінилось — ідемо спати - if (jsonStr === __lastSavedJson) { - return - } +/** Saves the blocks and sends them to Android unless they are what it already has. */ +function sendContent () { + cancelPendingSave() + if (!editor) return Promise.resolve() - // Оновлюємо останнє збереження + return editor + .save() + .then(output => { + const jsonStr = JSON.stringify(output.blocks) + if (jsonStr === __lastSavedJson) return __lastSavedJson = jsonStr - - // Шлемо в Android safeAndroidCall('onContentChanged', jsonStr) }) .catch(err => console.error('[Editor] Save failed:', err)) @@ -69,6 +85,7 @@ function saveContent () { * Android asks for this when the screen stops, so the last keystrokes are saved too. */ function flushContent () { + cancelPendingSave() if (!editor) return Promise.resolve() return editor @@ -405,8 +422,16 @@ function readDocument () { })) } +/** + * A state is kept once, as JSON: keeping the parsed document too doubled the memory of up to + * HISTORY_LIMIT copies of the note. The document is parsed back only when a step is applied. + */ function historyEntry (doc, caret) { - return { doc, json: JSON.stringify(doc), caret } + return { json: JSON.stringify(doc), caret } +} + +function historyDoc (entry) { + return JSON.parse(entry.json) } function reportHistory () { @@ -583,7 +608,7 @@ function captureHistory (force) { else __hist.batchHasText = false const entry = historyEntry(doc, historyCaret()) if (entry.json !== __hist.current.json) { - keepAttachmentsForUndo(__hist.current.doc, doc) + keepAttachmentsForUndo(historyDoc(__hist.current), doc) __hist.undo.push({ ...__hist.current, caret: caretBefore || __hist.current.caret }) if (__hist.undo.length > HISTORY_LIMIT) __hist.undo.shift() __hist.redo = [] @@ -662,8 +687,8 @@ function restoreHistoryCaret (caret, keep, fallbackId) { /** Puts the document of {@code entry} on screen with the smallest change that gets there. */ function applyHistoryEntry (entry) { - const from = __hist.current.doc - const to = entry.doc + const from = historyDoc(__hist.current) + const to = historyDoc(entry) const epoch = __hist.epoch __hist.applying = true keepAttachmentsForUndo(from, to) @@ -742,9 +767,10 @@ function historyRedo () { */ function forgetHistoryBlock (blockId) { const strip = entry => { - const blocks = entry.doc.blocks.filter(block => block.id !== blockId) - if (blocks.length === entry.doc.blocks.length) return entry - return historyEntry({ title: entry.doc.title, blocks }, entry.caret) + const doc = historyDoc(entry) + const blocks = doc.blocks.filter(block => block.id !== blockId) + if (blocks.length === doc.blocks.length) return entry + return historyEntry({ title: doc.title, blocks }, entry.caret) } const distinct = list => list.filter((entry, i) => i === 0 || entry.json !== list[i - 1].json) if (__hist.current) __hist.current = strip(__hist.current) @@ -837,6 +863,31 @@ function setThemeColors (colors) { } } +/** + * A plain-text note (from the simple editor, shared into the app or synced) as paragraph blocks. + * + * It used to become a single paragraph with a
per line. Every keystroke in a block makes the + * browser lay out that whole block again, so typing in a long note cost more the longer it was. + * Now each line of text is a paragraph, as if it had been typed here with Enter. A blank line + * cannot be a block of its own (the editor drops empty blocks when it saves), so blank lines go + * in front of the next line as
. Android joins blocks back with a newline, which gives exactly + * the text that came in; blank lines before the first and after the last line are left out, as + * Android trims them anyway. + */ +function plainTextBlocks (text) { + const blocks = [] + let blanks = '' + for (const line of text.split('\n')) { + if (line.trim() === '') { + if (blocks.length) blanks += line + '
' + continue + } + blocks.push({ type: 'paragraph', data: { text: blanks + line } }) + blanks = '' + } + return blocks +} + /** * Load note into editor */ @@ -849,12 +900,7 @@ function loadNote (note) { let blocks = [] if (note.plainTextFallback && note.plainText) { - blocks = [ - { - type: 'paragraph', - data: { text: note.plainText.replace(/\n/g, '
') } - } - ] + blocks = plainTextBlocks(note.plainText) } else if (note.valueJson) { blocks = note.valueJson } @@ -1177,6 +1223,7 @@ window.uploadAttachment = uploadAttachment window.toggleReadModeFromAndroid = toggleReadModeFromAndroid window.setReadMode = setReadMode window.saveContent = saveContent +window.plainTextBlocks = plainTextBlocks window.currentBlockIndex = currentBlockIndex window.insertUploadedBlockFromAndroid = insertUploadedBlockFromAndroid window.flushContent = flushContent diff --git a/notes_editor/test/save.test.mjs b/notes_editor/test/save.test.mjs new file mode 100644 index 00000000..6628e7c1 --- /dev/null +++ b/notes_editor/test/save.test.mjs @@ -0,0 +1,118 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openPage, fakeEditor, settle } from './page.mjs' + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +async function loadedPage () { + const page = openPage() + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'one' } }]) + let saves = 0 + const save = editor.save + editor.save = () => { saves++; return save() } + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + return { page, state, saves: () => saves } +} + +test('a burst of changes sends the document to Android once, after typing pauses', async () => { + const { page, state, saves } = await loadedPage() + const before = saves() + + for (let i = 0; i < 10; i++) { + state.blocks[0].data = { text: 'one' + 'x'.repeat(i + 1) } + page.window.saveContent() + } + await settle() + assert.equal(page.callsTo('onContentChanged').length, 0, 'nothing is sent while typing') + + await wait(800) + const sent = page.callsTo('onContentChanged') + assert.equal(sent.length, 1) + assert.equal(JSON.parse(sent[0][0])[0].data.text, 'one' + 'x'.repeat(10)) + assert.equal(saves() - before, 1, 'the document is serialized once for the whole burst') +}) + +test('a flush sends the latest document at once and the pending send is dropped', async () => { + const { page, state } = await loadedPage() + + state.blocks[0].data = { text: 'one two' } + page.window.saveContent() + await page.window.flushContent() + + const flushed = page.callsTo('onContentFlushed') + assert.equal(flushed.length, 1) + assert.equal(JSON.parse(flushed[0][0])[0].data.text, 'one two') + + await wait(800) + assert.equal(page.callsTo('onContentChanged').length, 0, 'the same document is not sent twice') +}) + +test('steady typing still reaches Android every few seconds', async () => { + const { page, state } = await loadedPage() + + const started = Date.now() + let i = 0 + while (Date.now() - started < 3400) { + state.blocks[0].data = { text: 'one ' + i++ } + page.window.saveContent() + await wait(100) + } + + assert.ok(page.callsTo('onContentChanged').length >= 1, 'a change was sent before the pause') +}) + +test('history states keep the document once, as JSON', async () => { + const { page, state } = await loadedPage() + + state.blocks[0].data = { text: 'one two' } + page.fire('document', 'beforeinput', { inputType: 'insertText', data: ' ' }) + page.window.historyEditorChanged({ type: 'block-changed' }) + await wait(800) + + const hist = page.window.__internals.history + assert.equal(hist.undo.length, 1) + for (const entry of [...hist.undo, hist.current]) { + assert.equal(entry.doc, undefined) + assert.equal(typeof entry.json, 'string') + } + + page.window.historyUndo() + await settle() + assert.equal(state.blocks[0].data.text, 'one') +}) + +/** What Android does when it turns the blocks back into plain text: texts joined with newlines. */ +const toPlain = blocks => blocks.map(b => b.data.text.replace(/
/g, '\n')).join('\n') + +test('a plain-text note opens with a block per line and gives back the same text', () => { + const page = openPage() + const lines = [] + for (let i = 0; i < 1300; i++) lines.push(i % 7 === 3 ? '' : `paragraph ${i}: the quick brown fox`) + lines[10] = ' ' + const text = lines.join('\n') + + const blocks = page.window.plainTextBlocks(text) + + assert.equal(toPlain(blocks), text) + for (const block of blocks) { + assert.equal(block.type, 'paragraph') + const shown = block.data.text.split('
') + assert.notEqual(shown.at(-1).trim(), '', 'every block ends with a line of text, so none is empty') + for (const blank of shown.slice(0, -1)) assert.equal(blank.trim(), '', 'only blank lines lead a block') + } + assert.equal(blocks.length, lines.filter(l => l.trim() !== '').length) +}) + +test('blank lines become space before the next line', () => { + const page = openPage() + const blocks = page.window.plainTextBlocks('one\n\ntwo\nthree') + assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), ['one', '
two', 'three']) +}) + +test('blank lines around the note are left out, as Android trims them', () => { + const page = openPage() + const blocks = page.window.plainTextBlocks('\n\none\ntwo\n\n') + assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), ['one', 'two']) +}) From 3d72fe79035ab9a1db0deeff47a701bc8c21d188 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 14:39:58 +0200 Subject: [PATCH 03/52] perf(editor): cut very long plain-text notes into kilobyte blocks 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. --- app/src/main/assets/editor/js/runtime.min.js | 2 +- notes_editor/src/custom/runtime.js | 56 ++++++++++++++++---- notes_editor/test/save.test.mjs | 19 ++++++- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/app/src/main/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js index 422fdc2c..34d30712 100644 --- a/app/src/main/assets/editor/js/runtime.min.js +++ b/app/src/main/assets/editor/js/runtime.min.js @@ -2,4 +2,4 @@ * My Notes — Editor Runtime Build * (c) 2025 pasichDev. Editor.js — MIT */ -!function(){"use strict";let e=null,t=!1,n=null;function safeAndroidCall(e,...t){if(window.Android&&"function"==typeof window.Android[e])try{return window.Android[e](...t)}catch(t){console.error(`[AndroidBridge] ${e} failed:`,t)}return null}window.attachEditorInstance=function(n){e=n,window.__EDITOR_READY=!0,t=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");let r=null,i=0;function saveContent(){if(!e)return;const t=Date.now();r||(i=t),clearTimeout(r);r=setTimeout(sendContent,t-i>=3e3?0:700)}function cancelPendingSave(){clearTimeout(r),r=null}function sendContent(){return cancelPendingSave(),e?e.save().then(e=>{const t=JSON.stringify(e.blocks);t!==n&&(n=t,safeAndroidCall("onContentChanged",t))}).catch(e=>console.error("[Editor] Save failed:",e)):Promise.resolve()}function flushContent(){return cancelPendingSave(),e?e.save().then(e=>{const t=JSON.stringify(e.blocks);t!==n&&(n=t,safeAndroidCall("onContentFlushed",t))}).catch(e=>console.error("[Editor] Flush failed:",e)):Promise.resolve()}let l=null;function reportViewportAnchor(){if(!e)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const t=e.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(l),l=setTimeout(reportViewportAnchor,200)},{passive:!0});let c=null,s=!1;function blockForHolder(t){const n=e.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/t):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const t=e.blocks.getBlocksCount();for(let o=0;o0){n.topId=t.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){e&&s&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let a=0;function reportViewStateNow(){clearTimeout(c),a=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(c),Date.now()-a>=250&&(a=Date.now(),reportViewState()),c=setTimeout(reportViewStateNow,200)}function placeCaret(e,t){const n=document.createTreeWalker(e,NodeFilter.SHOW_TEXT);let o=Math.max(0,t||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(e,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!e||!n)return;if(n.caretId&&!t){const t=e.blocks.getById(n.caretId);if(t?.holder){try{e.caret.setToBlock(t,"start")}catch(e){console.error("[Editor] setToBlock failed:",e)}const o=editableInputs(t.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?e.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const t=e.blocks.getBlocksCount();t>0&&(o=e.blocks.getBlockByIndex(Math.min(n.topIndex,t-1)))}o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(n.topOffset||0))})}function keepCaretVisible(){const e=window.getSelection();if(!e||0===e.rangeCount)return;const t=e.focusNode,n=t&&(1===t.nodeType?t:t.parentElement);if(!n||!n.isContentEditable)return;const o=e.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const d=100,u={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!e||t)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const e=document.createRange();return e.selectNodeContents(o),e.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:e.toString().length}}return caretPosition()}function readDocument(){return e.save().then(e=>({title:historyTitleElement()?.innerText||"",blocks:e.blocks}))}function historyEntry(e,t){return{json:JSON.stringify(e),caret:t}}function historyDoc(e){return JSON.parse(e.json)}function reportHistory(){const e=u.undo.length>0||u.pending,t=u.redo.length>0&&!u.pending,n=`${e}|${t}`;n!==u.reported&&(u.reported=n,safeAndroidCall("onHistoryChanged",e,t))}function attachmentUrls(e){const t=new Set;for(const n of e?.blocks||[]){const e=n?.data;if(e?.file?.url&&t.add(e.file.url),Array.isArray(e?.files))for(const n of e.files)n?.url&&t.add(n.url)}return t}function keepAttachmentsForUndo(e,t){const n=attachmentUrls(t),o=[...attachmentUrls(e)].filter(e=>!n.has(e));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){u.epoch++,clearTimeout(u.timer),u.timer=null,u.undo=[],u.redo=[],u.current=null,u.pending=!1,u.caretBefore=null,u.lastCaret=null,u.applying=!1,u.settleUntil=0,reportHistory()}const f=15e5;let p=null;function exportedEntry(e){return{json:e.json,caret:e.caret||null}}function importedEntry(e){return historyEntry(JSON.parse(e.json),e.caret||null)}function resetHistory(n){if(stopHistory(),!e||t)return;const o=u.epoch;readDocument().then(e=>{o===u.epoch&&(u.current=historyEntry(e,null),n&&(u.settleUntil=Date.now()+600),function(){const e=p;if(p=null,e?.current&&e.current.json===u.current?.json)try{u.undo=(e.undo||[]).map(importedEntry).slice(-d),u.redo=(e.redo||[]).map(importedEntry).slice(-d),u.current.caret=e.current.caret||null}catch(e){console.error("[History] import failed:",e),u.undo=[],u.redo=[]}}(),reportHistory())}).catch(e=>console.error("[History] reset failed:",e))}function historyNoteChange(e,n){if(!u.current||u.applying||t)return;if(n&&Date.now()=3e3;u.timer=setTimeout(captureHistory,e||o?0:700),reportHistory()}function historyInputStarting(){!u.current||u.applying||t||u.pending||u.caretBefore||(u.caretBefore=historyCaret())}function captureHistory(t){if(clearTimeout(u.timer),u.timer=null,!e||!u.current||!u.pending&&!t)return Promise.resolve();const n=u.epoch,o=u.changeSeq;return readDocument().then(e=>{if(n!==u.epoch||!u.current)return;const t=u.caretBefore,r=o!==u.changeSeq;u.pending=r,u.caretBefore=null,r?u.batchStart=Date.now():u.batchHasText=!1;const i=historyEntry(e,historyCaret());i.json!==u.current.json&&(keepAttachmentsForUndo(historyDoc(u.current),e),u.undo.push({...u.current,caret:t||u.current.caret}),u.undo.length>d&&u.undo.shift(),u.redo=[],u.current=i),reportHistory()})}function findBlock(t){if(!t)return null;const n=e.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===u.epoch&&(u.current=historyEntry(n,t.caret),u.settleUntil=Date.now()+600,function(t,n,o){const r=historyTitleElement();if(t?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,t.offset);else if(t?.id&&findBlock(t.id))restoreViewState({caretId:t.id,caretInput:t.input,caretOffset:t.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const t=findBlock(o);if(t)try{e.caret.setToBlock(t,"end")}catch(e){console.error("[History] placing the caret failed:",e)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(t.caret,c,s),saveContent())}).catch(e=>console.error("[History] applying a step failed:",e)).finally(()=>{r===u.epoch&&(u.applying=!1),reportHistory()})}function runHistoryAction(e){return u.queue=u.queue.then(e).catch(e=>console.error("[History]",e)),u.queue}function historyUndo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=u.undo.pop();if(e&&u.current)return u.redo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(e)}))}function historyRedo(){e&&!t&&runHistoryAction(()=>captureHistory(!0).then(()=>{const e=u.redo.pop();if(e&&u.current)return u.undo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(e)}))}function plainTextBlocks(e){const t=[];let n="";for(const o of e.split("\n"))""!==o.trim()?(t.push({type:"paragraph",data:{text:n+o}}),n=""):t.length&&(n+=o+"
");return t}document.addEventListener("keydown",e=>{const t=function(e){if(!e.ctrlKey&&!e.metaKey||e.altKey)return null;const t=(e.key||"").toLowerCase();return"z"===t?e.shiftKey?"redo":"undo":"y"!==t||e.shiftKey?null:"redo"}(e);if(t)return e.preventDefault(),e.stopPropagation(),void("undo"===t?historyUndo():historyRedo());(function(e){if(e.ctrlKey||e.metaKey||e.altKey)return!1;if(229===e.keyCode)return!0;const t=e.key||"";return 1===t.length||"Enter"===t||"Backspace"===t||"Delete"===t||"Tab"===t})(e)&&(historyInputStarting(),"Enter"===e.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",e=>{if("historyUndo"===e.inputType||"historyRedo"===e.inputType)return e.preventDefault(),void("historyUndo"===e.inputType?historyUndo():historyRedo());historyInputStarting();const t="string"==typeof e.data?e.data:"";historyNoteChange("insertParagraph"===e.inputType||"insertLineBreak"===e.inputType||/\s$/.test(t)&&(u.batchHasText||/\S/.test(t)),!1),/\S/.test(t)&&(u.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{u.pending||u.applying||(u.lastCaret=historyCaret())});const h=new Map;let y=0;function requestUpload(e,t){return new Promise(n=>{const o=`u${Date.now()}_${++y}`;h.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,e,t.name||"",t.size||0)&&function(e){return new Promise((t,n)=>{const o=new FileReader;o.onload=()=>t(o.result),o.onerror=n,o.readAsDataURL(e)})}(t).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,e,n,t.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(e,t){const n=h.get(e);n&&(h.delete(e),n(t))}function updateTitlePlaceholder(){const e=document.getElementById("noteTitleInput");e.innerText.trim()?e.removeAttribute("data-placeholder"):e.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(t,n){if(e)try{const o=e.blocks.getById(t);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;e.blocks.delete(i),function(e){const strip=t=>{const n=historyDoc(t),o=n.blocks.filter(t=>t.id!==e);return o.length===n.blocks.length?t:historyEntry({title:n.title,blocks:o},t.caret)},distinct=e=>e.filter((t,n)=>0===n||t.json!==e[n-1].json);u.current&&(u.current=strip(u.current));const t=distinct(u.undo.map(strip));u.current&&t.length&&t[t.length-1].json===u.current.json&&t.pop();const n=distinct(u.redo.map(strip).reverse());u.current&&n.length&&n[0].json===u.current.json&&n.shift(),u.undo=t,u.redo=n.reverse(),reportHistory()}(t),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",t,n)},30)}catch(e){console.error("[Delete] JS ERROR:",e)}};const g=document.getElementById("noteTitleInput");function applyTitleEditable(){const e=document.getElementById("noteTitleInput");e&&(e.setAttribute("contenteditable",t?"false":"true"),t&&document.activeElement===e&&e.blur())}function setReadMode(n,o){if(!e?.readOnly)return Promise.resolve(t);const r=currentViewState();return t=n,applyTitleEditable(),e.readOnly.toggle(n).then(()=>(n||u.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}g.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",g.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!t||!e)return;const r=function(e,t){const n=document.caretRangeFromPoint?.(e,t);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(e=>e.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(e){if(!e)return;const t=document.documentElement;for(const n in e)t.style.setProperty(`--${n}`,e[n])},window.loadNote=function(o){if(!e)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),s=!1,stopHistory(),p=o.history||null,e.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(t){if(!e||!t||t.index<0)return;const n=e.blocks.getBlocksCount();if(0===n)return;const o=e.blocks.getBlockByIndex(Math.min(t.index,n-1));o?.holder&&requestAnimationFrame(()=>{const e=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+e-(t.offset||0))})}(o.anchor),o.focusStart&&!t&&!o.viewState?.caretId){const t=o.viewState;let n=t?.topId?e.blocks.getById(t.topId):null;if(!n&&t?.topIndex>=0){const o=e.blocks.getBlocksCount();o>0&&(n=e.blocks.getBlockByIndex(Math.min(t.topIndex,o-1)))}try{n?e.caret.setToBlock(n,"start"):e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{s=!0,reportViewState()}))})},window.uploadAttachment=async function(e){const t=await requestUpload("file",e);return t&&t.success&&t.file?{success:1,file:{url:t.file.url,name:e.name,size:e.size,extension:e.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!t,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return e?e.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(t,n,o){if(!e||!n||!n.url)return;const r=e.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===t?e.blocks.insert("image",{file:n},void 0,i,!1):e.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(e){return(e?runHistoryAction(()=>(t?Promise.resolve():captureHistory(!0)).then(()=>{if(!u.current)return;const e=u.undo.map(exportedEntry),t=u.redo.map(exportedEntry),size=e=>e.reduce((e,t)=>e+t.json.length,0);let n=u.current.json.length+size(e)+size(t);for(;n>f&&(e.length||t.length);)n-=(e.length>=t.length?e.shift():t.shift()).json.length;n>f||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(u.current),undo:e,redo:t}))})):Promise.resolve()).catch(e=>console.error("[History] export failed:",e)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return e&&s?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!e||t)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{e.caret.setToFirstBlock("start")}catch(e){console.error("[Editor] placing the caret failed:",e)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(e){historyNoteChange((Array.isArray(e)?e:[e]).some(e=>e&&e.type&&"block-changed"!==e.type),!0)},window.uploadImage=async function(e){const t=await requestUpload("image",e);return t&&t.success&&t.file?{success:1,file:t.file}:{success:0}}}(); +!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");let r=null,i=0;function saveContent(){if(!t)return;const e=Date.now();r||(i=e),clearTimeout(r);r=setTimeout(sendContent,e-i>=3e3?0:700)}function cancelPendingSave(){clearTimeout(r),r=null}function sendContent(){return cancelPendingSave(),t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t)):Promise.resolve()}function flushContent(){return cancelPendingSave(),t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let l=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(l),l=setTimeout(reportViewportAnchor,200)},{passive:!0});let c=null,s=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&s&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let a=0;function reportViewStateNow(){clearTimeout(c),a=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(c),Date.now()-a>=250&&(a=Date.now(),reportViewState()),c=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const d=100,u={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=u.undo.length>0||u.pending,e=u.redo.length>0&&!u.pending,n=`${t}|${e}`;n!==u.reported&&(u.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){u.epoch++,clearTimeout(u.timer),u.timer=null,u.undo=[],u.redo=[],u.current=null,u.pending=!1,u.caretBefore=null,u.lastCaret=null,u.applying=!1,u.settleUntil=0,reportHistory()}const f=15e5;let p=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=u.epoch;readDocument().then(t=>{o===u.epoch&&(u.current=historyEntry(t,null),n&&(u.settleUntil=Date.now()+600),function(){const t=p;if(p=null,t?.current&&t.current.json===u.current?.json)try{u.undo=(t.undo||[]).map(importedEntry).slice(-d),u.redo=(t.redo||[]).map(importedEntry).slice(-d),u.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),u.undo=[],u.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n){if(!u.current||u.applying||e)return;if(n&&Date.now()=3e3;u.timer=setTimeout(captureHistory,t||o?0:700),reportHistory()}function historyInputStarting(){!u.current||u.applying||e||u.pending||u.caretBefore||(u.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(u.timer),u.timer=null,!t||!u.current||!u.pending&&!e)return Promise.resolve();const n=u.epoch,o=u.changeSeq;return readDocument().then(t=>{if(n!==u.epoch||!u.current)return;const e=u.caretBefore,r=o!==u.changeSeq;u.pending=r,u.caretBefore=null,r?u.batchStart=Date.now():u.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==u.current.json&&(keepAttachmentsForUndo(historyDoc(u.current),t),u.undo.push({...u.current,caret:e||u.current.caret}),u.undo.length>d&&u.undo.shift(),u.redo=[],u.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===u.epoch&&(u.current=historyEntry(n,e.caret),u.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,c,s),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===u.epoch&&(u.applying=!1),reportHistory()})}function runHistoryAction(t){return u.queue=u.queue.then(t).catch(t=>console.error("[History]",t)),u.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=u.undo.pop();if(t&&u.current)return u.redo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=u.redo.pop();if(t&&u.current)return u.undo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange("insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(u.batchHasText||/\S/.test(e)),!1),/\S/.test(e)&&(u.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{u.pending||u.applying||(u.lastCaret=historyCaret())});function plainTextBlocks(t){const e=t.split("\n");for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const h=new Map;let y=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++y}`;h.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=h.get(t);n&&(h.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);u.current&&(u.current=strip(u.current));const e=distinct(u.undo.map(strip));u.current&&e.length&&e[e.length-1].json===u.current.json&&e.pop();const n=distinct(u.redo.map(strip).reverse());u.current&&n.length&&n[0].json===u.current.json&&n.shift(),u.undo=e,u.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const g=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||u.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}g.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",g.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(t=>t.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),s=!1,stopHistory(),p=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{s=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!u.current)return;const t=u.undo.map(exportedEntry),e=u.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=u.current.json.length+size(t)+size(e);for(;n>f&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>f||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(u.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&s?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index d8142af4..5ccae914 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -868,23 +868,57 @@ function setThemeColors (colors) { * * It used to become a single paragraph with a
per line. Every keystroke in a block makes the * browser lay out that whole block again, so typing in a long note cost more the longer it was. - * Now each line of text is a paragraph, as if it had been typed here with Enter. A blank line - * cannot be a block of its own (the editor drops empty blocks when it saves), so blank lines go - * in front of the next line as
. Android joins blocks back with a newline, which gives exactly - * the text that came in; blank lines before the first and after the last line are left out, as + * + * Up to PLAIN_TEXT_LINE_BLOCKS lines of text, each line is a paragraph, as if it had been typed + * here with Enter. A longer note is cut into blocks of about PLAIN_TEXT_BLOCK_CHARS instead: every + * block holds DOM and a mutation observer of its own, and a block per line made a 1,300-line note + * take 70 MB more in the WebView. + * + * Android joins blocks back with a newline, so both give back exactly the text that came in. A + * blank line cannot be a block of its own (the editor drops empty blocks when it saves): per line, + * blank lines go in front of the next line as
; in kilobyte blocks, a cut is only made between + * two lines that hold text. Blank lines before the first and after the last line are left out, as * Android trims them anyway. */ +const PLAIN_TEXT_LINE_BLOCKS = 200 +const PLAIN_TEXT_BLOCK_CHARS = 1000 + function plainTextBlocks (text) { + const lines = text.split('\n') + while (lines.length && lines[0].trim() === '') lines.shift() + while (lines.length && lines[lines.length - 1].trim() === '') lines.pop() + const textLines = lines.filter(line => line.trim() !== '').length + const paragraph = text => ({ type: 'paragraph', data: { text } }) + + if (textLines <= PLAIN_TEXT_LINE_BLOCKS) { + const blocks = [] + let blanks = '' + for (const line of lines) { + if (line.trim() === '') { + blanks += line + '
' + } else { + blocks.push(paragraph(blanks + line)) + blanks = '' + } + } + return blocks + } + const blocks = [] - let blanks = '' - for (const line of text.split('\n')) { - if (line.trim() === '') { - if (blocks.length) blanks += line + '
' - continue + let chunk = [] + let size = 0 + for (let i = 0; i < lines.length; i++) { + chunk.push(lines[i]) + size += lines[i].length + 1 + const next = lines[i + 1] + const canCut = next !== undefined && lines[i].trim() !== '' && next.trim() !== '' + if (size >= PLAIN_TEXT_BLOCK_CHARS && canCut) { + blocks.push(paragraph(chunk.join('
'))) + chunk = [] + size = 0 } - blocks.push({ type: 'paragraph', data: { text: blanks + line } }) - blanks = '' } + if (chunk.length) blocks.push(paragraph(chunk.join('
'))) return blocks } diff --git a/notes_editor/test/save.test.mjs b/notes_editor/test/save.test.mjs index 6628e7c1..09c2454e 100644 --- a/notes_editor/test/save.test.mjs +++ b/notes_editor/test/save.test.mjs @@ -89,7 +89,7 @@ const toPlain = blocks => blocks.map(b => b.data.text.replace(/
/g, '\n')).jo test('a plain-text note opens with a block per line and gives back the same text', () => { const page = openPage() const lines = [] - for (let i = 0; i < 1300; i++) lines.push(i % 7 === 3 ? '' : `paragraph ${i}: the quick brown fox`) + for (let i = 0; i < 201; i++) lines.push(i % 7 === 3 ? '' : `paragraph ${i}: the quick brown fox`) lines[10] = ' ' const text = lines.join('\n') @@ -116,3 +116,20 @@ test('blank lines around the note are left out, as Android trims them', () => { const blocks = page.window.plainTextBlocks('\n\none\ntwo\n\n') assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), ['one', 'two']) }) + +test('a very long plain-text note opens in blocks of about a kilobyte that give back the same text', () => { + const page = openPage() + const lines = [] + for (let i = 0; i < 1300; i++) lines.push(i % 7 === 3 ? '' : `paragraph ${i}: the quick brown fox`) + const text = lines.join('\n') + + const blocks = page.window.plainTextBlocks(text) + + assert.equal(toPlain(blocks), text) + assert.ok(blocks.length > 20 && blocks.length < 120, `${blocks.length} blocks`) + for (const block of blocks) { + assert.ok(block.data.text.length < 2500, 'each block stays small') + assert.ok(!block.data.text.startsWith('
') && !block.data.text.endsWith('
'), + 'no block starts or ends with an empty line, which the editor could drop') + } +}) From b370d10376b727bc26f830903d987dfa3d263e78 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 14:54:04 +0200 Subject: [PATCH 04/52] perf(editor): stop EmojiCompat relaying out the whole note on every keystroke 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. --- app/src/main/res/layout/activity_note.xml | 1 + .../SimpleEditorEmojiCompatTest.java | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorEmojiCompatTest.java diff --git a/app/src/main/res/layout/activity_note.xml b/app/src/main/res/layout/activity_note.xml index dd307208..b89d5c52 100644 --- a/app/src/main/res/layout/activity_note.xml +++ b/app/src/main/res/layout/activity_note.xml @@ -220,6 +220,7 @@ Date: Fri, 13 Nov 2026 15:10:23 +0200 Subject: [PATCH 05/52] =?UTF-8?q?fix(billing):=20third=20review=20round=20?= =?UTF-8?q?=E2=80=94=20main-thread=20callbacks,=20one=20consume=20path,=20?= =?UTF-8?q?client=20ended=20on=20destroy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../ui/view/activity/SupportActivity.java | 14 +- .../settings/MediaSettingsFragment.java | 39 +- .../editor/EditableLinkMovementMethod.java | 4 + .../utils/managers/BillingManager.java | 395 +++++++++--------- .../utils/managers/ThankedPurchases.java | 11 +- .../EditableLinkMovementMethodTest.java | 5 + .../utils/managers/ThankedPurchasesTest.java | 15 + 7 files changed, 271 insertions(+), 212 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/SupportActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/SupportActivity.java index 5ff43f31..54db17ea 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/SupportActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/SupportActivity.java @@ -230,9 +230,6 @@ public boolean onOptionsItemSelected(MenuItem item) { } private boolean finishActivity() { - if (billingManager != null) { - billingManager.destroy(); - } if (purchasesBottomSheet != null) { purchasesBottomSheet.dismiss(); } @@ -240,6 +237,17 @@ private boolean finishActivity() { return true; } + @Override + protected void onDestroy() { + // Every way out ends the billing client, a rotation or a recreation too: a client left + // open keeps this activity alive and goes on handling purchase updates for it. + if (billingManager != null) { + billingManager.destroy(); + billingManager = null; + } + super.onDestroy(); + } + @Override public void onBillingInitialized() { // Загружаем продукты для отображения diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java b/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java index c5d4de7d..8923d3bc 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java @@ -121,29 +121,38 @@ private void initListeners() { binding.volumeSeekBar.setOnSeekBarChangeListener( new SeekBar.OnSeekBarChangeListener() { + /** The system refused a change during this drag. */ + private boolean refused; + @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (fromUser && audioManager != null) { - try { - audioManager.setStreamVolume( - AudioManager.STREAM_NOTIFICATION, progress, 0); - } catch (SecurityException e) { - // With Do Not Disturb on, the system refuses volume changes - // that would toggle it unless the app has notification policy - // access. The slider goes back to the volume actually in effect. - Log.w(TAG, "Notification volume change refused", e); - seekBar.setProgress( - audioManager.getStreamVolume( - AudioManager.STREAM_NOTIFICATION)); - } + if (!fromUser || audioManager == null || refused) return; + try { + audioManager.setStreamVolume( + AudioManager.STREAM_NOTIFICATION, progress, 0); + } catch (SecurityException e) { + // With Do Not Disturb on, the system refuses volume changes that + // would toggle it unless the app has notification policy access. + // The rest of the drag is not tried again (every step would throw + // and log), and the slider goes back to the volume in effect. + Log.w(TAG, "Notification volume change refused: " + e.getMessage()); + refused = true; } } @Override - public void onStartTrackingTouch(SeekBar seekBar) {} + public void onStartTrackingTouch(SeekBar seekBar) { + refused = false; + } @Override - public void onStopTrackingTouch(SeekBar seekBar) {} + public void onStopTrackingTouch(SeekBar seekBar) { + if (refused && audioManager != null) { + seekBar.setProgress( + audioManager.getStreamVolume(AudioManager.STREAM_NOTIFICATION)); + } + refused = false; + } }); } diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java b/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java index 095601f3..e550f3ac 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java @@ -5,6 +5,7 @@ import android.text.Selection; import android.text.Spannable; import android.text.method.ArrowKeyMovementMethod; +import android.text.method.MetaKeyKeyListener; import android.text.method.MovementMethod; import android.text.style.URLSpan; import android.util.Log; @@ -68,6 +69,9 @@ public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event // selecting started, nothing was recorded and it selects from -1, which throws. The // selection is left as it was rather than taking the app down. Log.w(TAG, "Selection gesture without a recorded start", e); + // The platform would have ended selecting mode after this event; without that, Shift + // stays held and every later tap extends a selection. + MetaKeyKeyListener.resetMetaState(buffer); return true; } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java b/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java index 3a6d1cfd..77090441 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java +++ b/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java @@ -2,12 +2,16 @@ import android.app.Activity; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import com.android.billingclient.api.*; import com.pasich.mynotes.data.model.DonationProduct; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** Manages in-app billing for donation products via Google Play Billing. */ public class BillingManager implements PurchasesUpdatedListener, BillingClientStateListener { @@ -31,6 +35,7 @@ public interface BillingManagerListener { private final BillingClient billingClient; private final BillingManagerListener listener; private final ThankedPurchases thankedPurchases; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); // Product IDs for donations private static final List DONATION_PRODUCT_IDS = @@ -63,15 +68,21 @@ private void startServiceConnection() { @Override public void onBillingSetupFinished(BillingResult billingResult) { + onMain(() -> handleSetupFinished(billingResult)); + } + + private void handleSetupFinished(BillingResult billingResult) { if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { Log.d(TAG, "Billing service connected successfully"); // Спочатку споживаємо всі наявні покупки - consumeAllExistingPurchases(() -> { - // The listener queries purchases itself once billing is initialized. - listener.onBillingInitialized(); - queryProductDetails(); - }); + consumeSettledPurchases( + null, + () -> { + // The listener queries purchases itself once billing is initialized. + listener.onBillingInitialized(); + queryProductDetails(); + }); } else { Log.e(TAG, "Failed to connect to billing service: " + billingResult.getDebugMessage()); listener.onBillingError("Failed to connect to billing service"); @@ -79,11 +90,25 @@ public void onBillingSetupFinished(BillingResult billingResult) { } /** - * Consumes every purchase left from earlier sessions. Purchases the user was never thanked for - * (completed while the app was closed, or pending ones that settled in the background) get a - * single thank-you for the whole batch once they are consumed. + * Play Billing may answer on a thread of its own. Every answer is handled on the main thread, + * so the counters below and the record of thanked purchases are only ever touched there. + */ + private void onMain(Runnable work) { + if (Looper.myLooper() == Looper.getMainLooper()) work.run(); + else mainHandler.post(work); + } + + /** + * Consumes the settled purchases left over (every one, or only those of {@code productId}), + * then runs {@code onComplete}. The one place purchases are consumed outside the purchase flow: + * at start, before a new purchase and after ITEM_ALREADY_OWNED. + * + *

Purchases the user was never thanked for (completed while the app was closed, or pending + * ones that settled in the background) get a single thank-you for the whole batch once they are + * consumed. Whether a purchase was thanked is read when its consume finishes, so a purchase the + * purchase flow thanks meanwhile is not thanked twice. */ - private void consumeAllExistingPurchases(Runnable onComplete) { + private void consumeSettledPurchases(String productId, Runnable onComplete) { QueryPurchasesParams params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) @@ -91,47 +116,61 @@ private void consumeAllExistingPurchases(Runnable onComplete) { billingClient.queryPurchasesAsync( params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) { - Log.e( - TAG, - "Failed to query existing purchases for consumption: " - + billingResult.getDebugMessage()); - onComplete.run(); - return; - } - List settled = new ArrayList<>(); - for (Purchase purchase : purchasesList) { - if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { - settled.add(purchase); - } - } - if (settled.isEmpty()) { - Log.d(TAG, "No existing purchases to consume"); - onComplete.run(); - return; - } - - Log.d(TAG, "Found " + settled.size() + " existing purchases, consuming them"); - int[] remaining = {settled.size()}; - String[] unthankedProduct = {null}; - for (Purchase purchase : settled) { - boolean thanked = thankedPurchases.wasThanked(purchase.getPurchaseToken()); - consumePurchase( - purchase, - consumed -> { - if (consumed && !thanked && !purchase.getProducts().isEmpty()) { - unthankedProduct[0] = purchase.getProducts().get(0); - } - if (--remaining[0] == 0) { - if (unthankedProduct[0] != null) { - listener.onPurchaseSuccessful(unthankedProduct[0]); - } - onComplete.run(); - } - }); - } - }); + (billingResult, purchasesList) -> + onMain(() -> { + if (billingResult.getResponseCode() + != BillingClient.BillingResponseCode.OK) { + Log.e( + TAG, + "Failed to query existing purchases: " + + billingResult.getDebugMessage()); + onComplete.run(); + return; + } + if (productId == null) { + // Tokens of purchases that are gone (refunded, revoked) are + // dropped from the record. + Set owned = new HashSet<>(); + for (Purchase purchase : purchasesList) { + owned.add(purchase.getPurchaseToken()); + } + thankedPurchases.retainOnly(owned); + } + List settled = new ArrayList<>(); + for (Purchase purchase : purchasesList) { + if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED + && (productId == null + || purchase.getProducts().contains(productId))) { + settled.add(purchase); + } + } + if (settled.isEmpty()) { + onComplete.run(); + return; + } + + Log.d(TAG, "Consuming " + settled.size() + " existing purchases"); + int[] remaining = {settled.size()}; + String[] unthankedProduct = {null}; + for (Purchase purchase : settled) { + consumePurchase( + purchase, + (consumed, wasThanked) -> { + if (consumed + && !wasThanked + && !purchase.getProducts().isEmpty()) { + unthankedProduct[0] = purchase.getProducts().get(0); + } + if (--remaining[0] == 0) { + if (unthankedProduct[0] != null) { + listener.onPurchaseSuccessful( + unthankedProduct[0]); + } + onComplete.run(); + } + }); + } + })); } @Override @@ -155,26 +194,28 @@ private void queryProductDetails() { billingClient.queryProductDetailsAsync( queryProductDetailsParams, - (billingResult, productDetailsList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - List products = new ArrayList<>(); - for (ProductDetails productDetails : - productDetailsList.getProductDetailsList()) { - DonationProduct product = createDonationProduct(productDetails); - products.add(product); - } - - products.sort(DonationProduct.BY_PRICE); - - listener.onProductsLoaded(products); - } else { - Log.e( - TAG, - "Failed to query product details: " - + billingResult.getDebugMessage()); - listener.onBillingError("Failed to load products"); - } - }); + (billingResult, productDetailsList) -> + onMain(() -> { + if (billingResult.getResponseCode() + == BillingClient.BillingResponseCode.OK) { + List products = new ArrayList<>(); + for (ProductDetails productDetails : + productDetailsList.getProductDetailsList()) { + DonationProduct product = createDonationProduct(productDetails); + products.add(product); + } + + products.sort(DonationProduct.BY_PRICE); + + listener.onProductsLoaded(products); + } else { + Log.e( + TAG, + "Failed to query product details: " + + billingResult.getDebugMessage()); + listener.onBillingError("Failed to load products"); + } + })); } private DonationProduct createDonationProduct(ProductDetails productDetails) { @@ -209,53 +250,7 @@ private String getIconForProduct(String productId) { /** Initiates the purchase flow for the given product, consuming any stale purchase first. */ public void launchBillingFlow(Activity activity, String productId) { - // Спочатку перевіряємо і споживаємо наявні покупки цього товару - queryAndConsumeExistingPurchases( - productId, - () -> { - // Після споживання запускаємо новий billing flow - proceedWithBillingFlow(activity, productId); - }); - } - - private void queryAndConsumeExistingPurchases(String productId, Runnable onComplete) { - QueryPurchasesParams params = - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) - .build(); - - billingClient.queryPurchasesAsync( - params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - boolean foundExisting = false; - for (Purchase purchase : purchasesList) { - if (purchase.getProducts().contains(productId) - && purchase.getPurchaseState() - == Purchase.PurchaseState.PURCHASED) { - foundExisting = true; - Log.d( - TAG, - "Found existing purchase for " - + productId - + ", consuming it first"); - consumePurchase(purchase, consumed -> onComplete.run()); - break; - } - } - if (!foundExisting) { - // Немає наявних покупок, можемо продовжувати - onComplete.run(); - } - } else { - Log.e( - TAG, - "Failed to query existing purchases: " - + billingResult.getDebugMessage()); - // Пробуємо продовжити навіть при помилці - onComplete.run(); - } - }); + consumeSettledPurchases(productId, () -> proceedWithBillingFlow(activity, productId)); } private void proceedWithBillingFlow(Activity activity, String productId) { @@ -271,41 +266,63 @@ private void proceedWithBillingFlow(Activity activity, String productId) { billingClient.queryProductDetailsAsync( queryProductDetailsParams, - (billingResult, productDetailsList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK - && !productDetailsList.getProductDetailsList().isEmpty()) { - - ProductDetails productDetails = - productDetailsList.getProductDetailsList().get(0); - List productDetailsParamsList = - List.of( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .build()); - - BillingFlowParams billingFlowParams = - BillingFlowParams.newBuilder() - .setProductDetailsParamsList(productDetailsParamsList) - .build(); - - BillingResult result = - billingClient.launchBillingFlow(activity, billingFlowParams); - if (result.getResponseCode() != BillingClient.BillingResponseCode.OK) { - Log.e( - TAG, - "Failed to launch billing flow: " + result.getDebugMessage()); - listener.onPurchaseFailed( - result.getResponseCode(), result.getDebugMessage()); - } - } else { - Log.e(TAG, "Product not found: " + productId); - listener.onBillingError("Product not found"); - } - }); + (billingResult, productDetailsList) -> + onMain(() -> { + if (billingResult.getResponseCode() + != BillingClient.BillingResponseCode.OK) { + // Billing unavailable, disconnected, no network: say which, rather + // than + // claiming the product does not exist. + Log.e( + TAG, + "Failed to load product: " + + billingResult.getDebugMessage()); + listener.onPurchaseFailed( + billingResult.getResponseCode(), + billingResult.getDebugMessage()); + } else if (!productDetailsList.getProductDetailsList().isEmpty()) { + + ProductDetails productDetails = + productDetailsList.getProductDetailsList().get(0); + List + productDetailsParamsList = + List.of( + BillingFlowParams.ProductDetailsParams + .newBuilder() + .setProductDetails(productDetails) + .build()); + + BillingFlowParams billingFlowParams = + BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + productDetailsParamsList) + .build(); + + BillingResult result = + billingClient.launchBillingFlow( + activity, billingFlowParams); + if (result.getResponseCode() + != BillingClient.BillingResponseCode.OK) { + Log.e( + TAG, + "Failed to launch billing flow: " + + result.getDebugMessage()); + listener.onPurchaseFailed( + result.getResponseCode(), result.getDebugMessage()); + } + } else { + Log.e(TAG, "Product not found: " + productId); + listener.onBillingError("Product not found"); + } + })); } @Override public void onPurchasesUpdated(BillingResult billingResult, List purchases) { + onMain(() -> handlePurchasesUpdated(billingResult, purchases)); + } + + private void handlePurchasesUpdated(BillingResult billingResult, List purchases) { if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) { for (Purchase purchase : purchases) { @@ -319,7 +336,7 @@ public void onPurchasesUpdated(BillingResult billingResult, List purch == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) { Log.d(TAG, "Item already owned, attempting to consume existing purchase"); // Спробуємо знайти і спожити наявну покупку - queryAndConsumeAllPurchases(); + consumeSettledPurchases(null, () -> {}); listener.onBillingError("Item already owned. Please try again in a moment."); } else { Log.e(TAG, "Purchase failed: " + billingResult.getDebugMessage()); @@ -328,25 +345,6 @@ public void onPurchasesUpdated(BillingResult billingResult, List purch } } - private void queryAndConsumeAllPurchases() { - QueryPurchasesParams params = - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) - .build(); - - billingClient.queryPurchasesAsync( - params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - for (Purchase purchase : purchasesList) { - if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { - consumePurchase(purchase, consumed -> {}); - } - } - } - }); - } - private void handlePurchase(Purchase purchase) { if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { // Get the first product ID from the purchase @@ -358,14 +356,14 @@ private void handlePurchase(Purchase purchase) { thankedPurchases.markThanked(purchase.getPurchaseToken()); // Consume the purchase immediately to allow repeated purchases - consumePurchase(purchase, consumed -> {}); + consumePurchase(purchase, (consumed, wasThanked) -> {}); } } } - /** Receives whether a consume succeeded; it is called whatever the result. */ + /** Receives whether a consume succeeded, and whether the purchase had been thanked for. */ private interface ConsumeCallback { - void onConsumeFinished(boolean consumed); + void onConsumeFinished(boolean consumed, boolean wasThanked); } private void consumePurchase(Purchase purchase, ConsumeCallback callback) { @@ -374,24 +372,30 @@ private void consumePurchase(Purchase purchase, ConsumeCallback callback) { billingClient.consumeAsync( consumeParams, - (billingResult, purchaseToken) -> { - boolean consumed = - billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK; - if (consumed) { - Log.d(TAG, "Purchase consumed successfully, can be purchased again"); - // A consumed purchase never comes back, so its thanks need no record. - thankedPurchases.forget(purchase.getPurchaseToken()); - } else { - Log.e( - TAG, - "Failed to consume purchase: " + billingResult.getDebugMessage()); - } - callback.onConsumeFinished(consumed); - }); + (billingResult, purchaseToken) -> + onMain(() -> { + boolean consumed = + billingResult.getResponseCode() + == BillingClient.BillingResponseCode.OK; + boolean wasThanked = + thankedPurchases.wasThanked(purchase.getPurchaseToken()); + if (consumed) { + Log.d(TAG, "Purchase consumed, it can be bought again"); + // A consumed purchase never comes back: its record goes. + thankedPurchases.forget(purchase.getPurchaseToken()); + } else { + Log.e( + TAG, + "Failed to consume purchase: " + + billingResult.getDebugMessage()); + } + callback.onConsumeFinished(consumed, wasThanked); + })); } /** Ends the billing client connection; call from Activity/Fragment onDestroy. */ public void destroy() { + mainHandler.removeCallbacksAndMessages(null); // Unconditional: with automatic service reconnection a dropped client is not ready but // still live, and would reconnect for a call still in flight. billingClient.endConnection(); @@ -434,13 +438,18 @@ public void queryPurchases() { billingClient.queryPurchasesAsync( params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - listener.onPurchasesLoaded(purchasesList); - } else { - Log.e(TAG, "Failed to query purchases: " + billingResult.getDebugMessage()); - listener.onPurchasesLoaded(new ArrayList<>()); - } - }); + (billingResult, purchasesList) -> + onMain(() -> { + if (billingResult.getResponseCode() + == BillingClient.BillingResponseCode.OK) { + listener.onPurchasesLoaded(purchasesList); + } else { + Log.e( + TAG, + "Failed to query purchases: " + + billingResult.getDebugMessage()); + listener.onPurchasesLoaded(new ArrayList<>()); + } + })); } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java b/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java index 9d27d8d1..041f7c02 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java +++ b/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java @@ -25,7 +25,7 @@ final class ThankedPurchases { } boolean wasThanked(String purchaseToken) { - return tokens().contains(purchaseToken); + return prefs.getStringSet(KEY_TOKENS, Set.of()).contains(purchaseToken); } void markThanked(String purchaseToken) { @@ -33,6 +33,15 @@ void markThanked(String purchaseToken) { if (tokens.add(purchaseToken)) save(tokens); } + /** + * Keeps only the tokens of purchases that still exist. A purchase refunded or revoked before + * its consume never comes back to be forgotten. + */ + void retainOnly(Set ownedTokens) { + Set tokens = tokens(); + if (tokens.retainAll(ownedTokens)) save(tokens); + } + void forget(String purchaseToken) { Set tokens = tokens(); if (tokens.remove(purchaseToken)) save(tokens); diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java index cf95abe0..b315f222 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java @@ -67,5 +67,10 @@ public void upInSelectingModeWithoutARecordedDown_doesNotCrashAndKeepsTheCaret() assertThat(handled).isTrue(); assertThat(Selection.getSelectionStart(buffer)).isEqualTo(caretBefore); assertThat(Selection.getSelectionEnd(buffer)).isEqualTo(caretBefore); + // The platform ends selecting mode after an up; skipping it would leave Shift stuck. + assertThat( + android.text.method.MetaKeyKeyListener.getMetaState( + buffer, KeyEvent.META_SHIFT_ON)) + .isEqualTo(0); } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java b/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java index 40f9b228..a6afc8a8 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java @@ -52,4 +52,19 @@ public void forgetDropsTheToken() { assertThat(later.wasThanked("token-a")).isFalse(); assertThat(later.wasThanked("token-b")).isTrue(); } + + /** A purchase refunded or revoked before its consume never comes back: its token goes. */ + @Test + public void retainOnlyDropsTokensOfPurchasesThatAreGone() { + ThankedPurchases thanked = new ThankedPurchases(context); + thanked.markThanked("owned"); + thanked.markThanked("refunded"); + + thanked.retainOnly(java.util.Set.of("owned", "never-thanked")); + + ThankedPurchases later = new ThankedPurchases(context); + assertThat(later.wasThanked("owned")).isTrue(); + assertThat(later.wasThanked("refunded")).isFalse(); + assertThat(later.wasThanked("never-thanked")).isFalse(); + } } From e8c709b6251841ad6e5c9550670aeb5d9e20ce22 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:22:22 +0200 Subject: [PATCH 06/52] fix(import): bring Google Keep checklists over with their items 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. --- .../models/googleKeep/GoogleKeepNote.java | 44 ++++++++++++++++- .../models/googleKeep/GoogleKeepNoteTest.java | 47 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java b/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java index edafcb90..33329532 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java @@ -21,10 +21,50 @@ public class GoogleKeepNote { @SerializedName("labels") private List labels; + /** A checklist note's items; Keep leaves {@code textContent} out for these. */ + @SerializedName("listContent") + private List listContent; + + /** One line of a Keep checklist. */ + public static class ListItem { + @SerializedName("text") + private String text; + + @SerializedName("isChecked") + private boolean isChecked; + } + public String getTextContent() { return textContent; } + /** + * The note's body as plain text. + * + *

A checklist note carries its items in {@code listContent}, not {@code textContent}. They + * are written the way the app itself turns a checklist into plain text ("[ ] item", "[x] + * item"), so the items and their state survive the import instead of arriving as an empty note. + */ + public String getBodyText() { + boolean hasText = textContent != null && !textContent.isEmpty(); + if (listContent == null || listContent.isEmpty()) { + return hasText ? textContent : ""; + } + StringBuilder body = new StringBuilder(); + if (hasText) { + body.append(textContent); + } + for (ListItem item : listContent) { + if (item == null) continue; + if (body.length() > 0) { + body.append('\n'); + } + body.append(item.isChecked ? "[x] " : "[ ] ") + .append(item.text == null ? "" : item.text); + } + return body.toString(); + } + public String getTitle() { return title; } @@ -53,13 +93,13 @@ public GoogleKeepLabel getFirstLabel() { public Note toAppNotes() { Note note = new Note(); long timestamp = userEditedTimestampUsec / 1000; - return note.create(title, textContent, timestamp, getFirstLabel().getName()); + return note.create(title, getBodyText(), timestamp, getFirstLabel().getName()); } public Note toAppNotesTrash() { Note note = new Note(); note.setTrash(true); long timestamp = userEditedTimestampUsec / 1000; - return note.create(title, textContent, timestamp, getFirstLabel().getName()); + return note.create(title, getBodyText(), timestamp, getFirstLabel().getName()); } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java b/app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java new file mode 100644 index 00000000..e6679a54 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java @@ -0,0 +1,47 @@ +package com.pasich.mynotes.utils.backup.models.googleKeep; + +import static org.junit.Assert.assertEquals; + +import com.google.gson.Gson; +import org.junit.Test; + +public class GoogleKeepNoteTest { + + private static GoogleKeepNote parse(String json) { + return new Gson().fromJson(json, GoogleKeepNote.class); + } + + @Test + public void textNote_keepsItsText() { + GoogleKeepNote note = parse("{\"title\":\"T\",\"textContent\":\"hello\"}"); + + assertEquals("hello", note.toAppNotes().getValue()); + } + + @Test + public void checklistNote_importsItsItemsWithTheirState() { + GoogleKeepNote note = + parse( + "{\"title\":\"Shopping\",\"listContent\":[" + + "{\"text\":\"Milk\",\"isChecked\":false}," + + "{\"text\":\"Bread\",\"isChecked\":true}]}"); + + assertEquals("[ ] Milk\n[x] Bread", note.toAppNotes().getValue()); + assertEquals("[ ] Milk\n[x] Bread", note.toAppNotesTrash().getValue()); + } + + @Test + public void checklistWithText_keepsBoth() { + GoogleKeepNote note = + parse( + "{\"textContent\":\"intro\",\"listContent\":[" + + "{\"text\":\"a\",\"isChecked\":false}]}"); + + assertEquals("intro\n[ ] a", note.getBodyText()); + } + + @Test + public void noContentAtAll_isEmptyNotNull() { + assertEquals("", parse("{\"title\":\"x\"}").getBodyText()); + } +} From f3527ac881bbda2e8824bea424d1c960a768e987 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:23:38 +0200 Subject: [PATCH 07/52] fix(editor): send each change to Android at once again 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. --- app/src/main/assets/editor/js/runtime.min.js | 2 +- notes_editor/src/custom/runtime.js | 31 +-------------- notes_editor/test/save.test.mjs | 41 ++++++-------------- 3 files changed, 14 insertions(+), 60 deletions(-) diff --git a/app/src/main/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js index 34d30712..0dcb0edc 100644 --- a/app/src/main/assets/editor/js/runtime.min.js +++ b/app/src/main/assets/editor/js/runtime.min.js @@ -2,4 +2,4 @@ * My Notes — Editor Runtime Build * (c) 2025 pasichDev. Editor.js — MIT */ -!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");let r=null,i=0;function saveContent(){if(!t)return;const e=Date.now();r||(i=e),clearTimeout(r);r=setTimeout(sendContent,e-i>=3e3?0:700)}function cancelPendingSave(){clearTimeout(r),r=null}function sendContent(){return cancelPendingSave(),t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t)):Promise.resolve()}function flushContent(){return cancelPendingSave(),t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let l=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(l),l=setTimeout(reportViewportAnchor,200)},{passive:!0});let c=null,s=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&s&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let a=0;function reportViewStateNow(){clearTimeout(c),a=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(c),Date.now()-a>=250&&(a=Date.now(),reportViewState()),c=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const d=100,u={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=u.undo.length>0||u.pending,e=u.redo.length>0&&!u.pending,n=`${t}|${e}`;n!==u.reported&&(u.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){u.epoch++,clearTimeout(u.timer),u.timer=null,u.undo=[],u.redo=[],u.current=null,u.pending=!1,u.caretBefore=null,u.lastCaret=null,u.applying=!1,u.settleUntil=0,reportHistory()}const f=15e5;let p=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=u.epoch;readDocument().then(t=>{o===u.epoch&&(u.current=historyEntry(t,null),n&&(u.settleUntil=Date.now()+600),function(){const t=p;if(p=null,t?.current&&t.current.json===u.current?.json)try{u.undo=(t.undo||[]).map(importedEntry).slice(-d),u.redo=(t.redo||[]).map(importedEntry).slice(-d),u.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),u.undo=[],u.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n){if(!u.current||u.applying||e)return;if(n&&Date.now()=3e3;u.timer=setTimeout(captureHistory,t||o?0:700),reportHistory()}function historyInputStarting(){!u.current||u.applying||e||u.pending||u.caretBefore||(u.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(u.timer),u.timer=null,!t||!u.current||!u.pending&&!e)return Promise.resolve();const n=u.epoch,o=u.changeSeq;return readDocument().then(t=>{if(n!==u.epoch||!u.current)return;const e=u.caretBefore,r=o!==u.changeSeq;u.pending=r,u.caretBefore=null,r?u.batchStart=Date.now():u.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==u.current.json&&(keepAttachmentsForUndo(historyDoc(u.current),t),u.undo.push({...u.current,caret:e||u.current.caret}),u.undo.length>d&&u.undo.shift(),u.redo=[],u.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===u.epoch&&(u.current=historyEntry(n,e.caret),u.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,c,s),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===u.epoch&&(u.applying=!1),reportHistory()})}function runHistoryAction(t){return u.queue=u.queue.then(t).catch(t=>console.error("[History]",t)),u.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=u.undo.pop();if(t&&u.current)return u.redo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=u.redo.pop();if(t&&u.current)return u.undo.push({...u.current,caret:historyCaret()||u.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange("insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(u.batchHasText||/\S/.test(e)),!1),/\S/.test(e)&&(u.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{u.pending||u.applying||(u.lastCaret=historyCaret())});function plainTextBlocks(t){const e=t.split("\n");for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const h=new Map;let y=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++y}`;h.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=h.get(t);n&&(h.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);u.current&&(u.current=strip(u.current));const e=distinct(u.undo.map(strip));u.current&&e.length&&e[e.length-1].json===u.current.json&&e.pop();const n=distinct(u.redo.map(strip).reverse());u.current&&n.length&&n[0].json===u.current.json&&n.shift(),u.undo=e,u.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const g=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||u.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}g.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",g.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(t=>t.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),s=!1,stopHistory(),p=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{s=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!u.current)return;const t=u.undo.map(exportedEntry),e=u.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=u.current.json.length+size(t)+size(e);for(;n>f&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>f||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(u.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&s?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); +!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){t&&t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t))}function flushContent(){return t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let s=0;function reportViewStateNow(){clearTimeout(i),s=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-s>=250&&(s=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const s=window.getSelection();s.removeAllRanges(),s.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const c=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=a.undo.length>0||a.pending,e=a.redo.length>0&&!a.pending,n=`${t}|${e}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=a.epoch;readDocument().then(t=>{o===a.epoch&&(a.current=historyEntry(t,null),n&&(a.settleUntil=Date.now()+600),function(){const t=u;if(u=null,t?.current&&t.current.json===a.current?.json)try{a.undo=(t.undo||[]).map(importedEntry).slice(-c),a.redo=(t.redo||[]).map(importedEntry).slice(-c),a.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n){if(!a.current||a.applying||e)return;if(n&&Date.now()=3e3;a.timer=setTimeout(captureHistory,t||o?0:700),reportHistory()}function historyInputStarting(){!a.current||a.applying||e||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(a.timer),a.timer=null,!t||!a.current||!a.pending&&!e)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(t=>{if(n!==a.epoch||!a.current)return;const e=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(historyDoc(a.current),t),a.undo.push({...a.current,caret:e||a.current.caret}),a.undo.length>c&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,e.caret),a.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,s,c),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(t){return a.queue=a.queue.then(t).catch(t=>console.error("[History]",t)),a.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.undo.pop();if(t&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.redo.pop();if(t&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange("insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(a.batchHasText||/\S/.test(e)),!1),/\S/.test(e)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});function plainTextBlocks(t){const e=t.split("\n");for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const f=new Map;let p=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=f.get(t);n&&(f.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);a.current&&(a.current=strip(a.current));const e=distinct(a.undo.map(strip));a.current&&e.length&&e[e.length-1].json===a.current.json&&e.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=e,a.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const s=[...i.querySelectorAll("[contenteditable]")],c=s.find(t=>t.contains(o));if(!c)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(c),a.setEnd(o,n.startOffset),{id:l.id,input:s.indexOf(c),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const t=a.undo.map(exportedEntry),e=a.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=a.current.json.length+size(t)+size(e);for(;n>d&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index 5ccae914..fafe4e52 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -38,38 +38,12 @@ const __pageParams = new URLSearchParams(window.location.search) const __doubleTapToEdit = __pageParams.get('dbltap') === '1' /** - * Sending the document is batched. Every send serializes the whole note (Editor.js saves and - * sanitizes every block, then it is stringified and copied to Android), so on a long note sending - * on each Editor.js change made typing cost grow with the note. Android saves only after it has - * been quiet for two seconds anyway; it gets the document once typing pauses, at least every few - * seconds while typing goes on, and at once through flushContent when the screen stops. + * Saves the blocks and sends them to Android unless they are what it already has. */ -const SAVE_IDLE_MS = 700 -const SAVE_MAX_WAIT_MS = 3000 -let __saveTimer = null -let __saveFirstRequest = 0 - -/** A change was made: the document goes to Android once typing pauses. */ function saveContent () { if (!editor) return - const now = Date.now() - if (!__saveTimer) __saveFirstRequest = now - clearTimeout(__saveTimer) - const overdue = now - __saveFirstRequest >= SAVE_MAX_WAIT_MS - __saveTimer = setTimeout(sendContent, overdue ? 0 : SAVE_IDLE_MS) -} - -function cancelPendingSave () { - clearTimeout(__saveTimer) - __saveTimer = null -} -/** Saves the blocks and sends them to Android unless they are what it already has. */ -function sendContent () { - cancelPendingSave() - if (!editor) return Promise.resolve() - - return editor + editor .save() .then(output => { const jsonStr = JSON.stringify(output.blocks) @@ -85,7 +59,6 @@ function sendContent () { * Android asks for this when the screen stops, so the last keystrokes are saved too. */ function flushContent () { - cancelPendingSave() if (!editor) return Promise.resolve() return editor diff --git a/notes_editor/test/save.test.mjs b/notes_editor/test/save.test.mjs index 09c2454e..0dbd4a89 100644 --- a/notes_editor/test/save.test.mjs +++ b/notes_editor/test/save.test.mjs @@ -16,51 +16,32 @@ async function loadedPage () { return { page, state, saves: () => saves } } -test('a burst of changes sends the document to Android once, after typing pauses', async () => { - const { page, state, saves } = await loadedPage() - const before = saves() +test('a change is sent to Android at once', async () => { + const { page, state } = await loadedPage() - for (let i = 0; i < 10; i++) { - state.blocks[0].data = { text: 'one' + 'x'.repeat(i + 1) } - page.window.saveContent() - } + state.blocks[0].data = { text: 'one two' } + page.window.saveContent() await settle() - assert.equal(page.callsTo('onContentChanged').length, 0, 'nothing is sent while typing') - await wait(800) const sent = page.callsTo('onContentChanged') assert.equal(sent.length, 1) - assert.equal(JSON.parse(sent[0][0])[0].data.text, 'one' + 'x'.repeat(10)) - assert.equal(saves() - before, 1, 'the document is serialized once for the whole burst') + assert.equal(JSON.parse(sent[0][0])[0].data.text, 'one two') }) -test('a flush sends the latest document at once and the pending send is dropped', async () => { +test('a flush sends only what Android does not have yet', async () => { const { page, state } = await loadedPage() state.blocks[0].data = { text: 'one two' } page.window.saveContent() + await settle() await page.window.flushContent() + assert.equal(page.callsTo('onContentFlushed').length, 0, 'the same document is not sent twice') + state.blocks[0].data = { text: 'one two three' } + await page.window.flushContent() const flushed = page.callsTo('onContentFlushed') assert.equal(flushed.length, 1) - assert.equal(JSON.parse(flushed[0][0])[0].data.text, 'one two') - - await wait(800) - assert.equal(page.callsTo('onContentChanged').length, 0, 'the same document is not sent twice') -}) - -test('steady typing still reaches Android every few seconds', async () => { - const { page, state } = await loadedPage() - - const started = Date.now() - let i = 0 - while (Date.now() - started < 3400) { - state.blocks[0].data = { text: 'one ' + i++ } - page.window.saveContent() - await wait(100) - } - - assert.ok(page.callsTo('onContentChanged').length >= 1, 'a change was sent before the pause') + assert.equal(JSON.parse(flushed[0][0])[0].data.text, 'one two three') }) test('history states keep the document once, as JSON', async () => { From 67d5a0379a3f16541e500b83eced1c83d3413bd3 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:24:09 +0200 Subject: [PATCH 08/52] fix(sync): stop a queued manual sync from undoing a disconnect 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. --- .../mynotes/ui/sync/SyncCoordinator.java | 7 ++- .../mynotes/ui/sync/SyncCoordinatorTest.java | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java index f4dfc4c3..75c0d876 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java @@ -255,6 +255,12 @@ public void syncNow(@NonNull Activity activity, @NonNull Callback cal new IllegalStateException("Confirm the first sync before continuing")); return; } + // Enabled here, on the calling thread and after the checks above, never on the worker. + // SyncService re-reads this flag under its lock precisely so a disconnect that lands while + // this sync waits for the token or the lock stops it; switching it back on from the worker + // just before the sync undid that disconnect and synced the signed-out account anyway. + // disconnect() runs on the same thread as this method, so the two cannot interleave here. + preferenceHelper.setSyncEnabled(true); googleDriveAuthorization.authorize( activity, new GoogleDriveAuthorization.Callback() { @@ -264,7 +270,6 @@ public void onAuthorized(@NonNull String accessToken) { callback, () -> { try { - preferenceHelper.setSyncEnabled(true); SyncState result = conflictStore.sync(accessToken); if (result.getStatus() == SyncState.Status.SUCCESS && preferenceHelper.isBackgroundSyncEnabled()) { diff --git a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java index c289aa99..88b1293f 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java @@ -303,6 +303,48 @@ public void syncNow_doesNotRequireAFeatureRollout() { assertThat(callback.error).isNull(); } + @Test + public void syncNow_queuedBehindADisconnectDoesNotTurnSyncBackOn() { + // The worker used to switch sync back on right before running it, so a disconnect tapped + // while the sync waited in the queue was undone and the signed-out account synced anyway. + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onAuthorized("access-token"); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + store.syncEnabledProbe = preferences; + List queued = new ArrayList<>(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + queued::add, + directExecutor); + + coordinator.syncNow(mock(Activity.class), new CapturingCallback<>()); + assertThat(preferences.syncEnabled).isTrue(); + coordinator.disconnect(new CapturingCallback<>()); + for (Runnable task : new ArrayList<>(queued)) { + task.run(); + } + + assertThat(store.syncEnabledAtSync).isFalse(); + assertThat(preferences.syncEnabled).isFalse(); + } + @Test public void resolveConflict_updatesStoreAndReturnsLatestConflicts() { FakePreferenceHelper preferences = new FakePreferenceHelper(); @@ -385,6 +427,8 @@ private static final class FakeConflictStore implements SyncCoordinator.Conflict private final List resolutions = new ArrayList<>(); private String lastToken; private int clearCalls; + private FakePreferenceHelper syncEnabledProbe; + private Boolean syncEnabledAtSync; @NonNull @Override @@ -410,6 +454,9 @@ public void resolveConflict(long conflictId, @NonNull SyncResolution resolution) @Override public SyncState sync(@NonNull String accessToken) { lastToken = accessToken; + if (syncEnabledProbe != null) { + syncEnabledAtSync = syncEnabledProbe.syncEnabled; + } return state; } From 0e362142393b25d380b29b6787e6f329df60edec Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:24:44 +0200 Subject: [PATCH 09/52] fix(tags): rebuild tag dialogs after recreation and keep their writes 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. --- .../dialogs/DeleteTagDialogContract.java | 5 +- .../dialogs/DeleteTagDialogPresenter.java | 31 ++-- .../ui/presenter/dialogs/DetachedWrites.java | 22 +++ .../dialogs/NameTagDialogPresenter.java | 36 ++--- .../ui/view/activity/MainActivity.java | 5 +- .../ui/view/activity/TagsActivity.java | 49 +++--- .../ui/view/dialogs/main/DeleteTagDialog.java | 46 ++++-- .../ui/view/dialogs/main/NameTagDialog.java | 45 +++++- .../mynotes/ui/view/dialogs/main/TagArgs.java | 43 +++++ .../dialogs/main/TagOptionsBottomSheet.java | 61 +++++-- .../presenter/TagDialogPresentersTest.java | 150 ++++++++++++++++++ .../dialogs/TagDialogsRecreationTest.java | 86 ++++++++++ 12 files changed, 481 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DetachedWrites.java create mode 100644 app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagArgs.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/DeleteTagDialogContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/DeleteTagDialogContract.java index d26821b9..9f34d5c4 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/DeleteTagDialogContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/DeleteTagDialogContract.java @@ -6,7 +6,10 @@ public interface DeleteTagDialogContract { - interface view extends BaseView {} + interface view extends BaseView { + /** Shows how many notes carry the tag, once it is known. */ + void showNotesCount(int count); + } interface presenter extends BasePresenter { void getLoadCountNotesForTag(String nameTag); diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DeleteTagDialogPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DeleteTagDialogPresenter.java index 98f0ca9a..cd1765a7 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DeleteTagDialogPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DeleteTagDialogPresenter.java @@ -1,5 +1,6 @@ package com.pasich.mynotes.ui.presenter.dialogs; +import android.util.Log; import com.pasich.mynotes.base.presenter.BasePresenter; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Tag; @@ -11,6 +12,8 @@ public class DeleteTagDialogPresenter extends BasePresenter implements DeleteTagDialogContract.presenter { + private static final String TAG = "DeleteTagDialogPresenter"; + private int countNotesForTag; @Inject @@ -33,27 +36,28 @@ public void getLoadCountNotesForTag(String nameTag) { getDataManager() .getCountNotesTag(nameTag) .subscribeOn(getSchedulerProvider().io()) - .subscribe(this::setCountNotesForTag)); + .observeOn(getSchedulerProvider().ui()) + .subscribe( + this::setCountNotesForTag, + error -> Log.e(TAG, "count notes for tag failed", error))); } @Override public void deleteTagUnchecked(Tag tag) { - getCompositeDisposable() - .add( - getDataManager() - .clearTagInNotes(tag) - .subscribeOn(getSchedulerProvider().io()) - .subscribe()); + DetachedWrites.run( + getDataManager().clearTagInNotes(tag), + getSchedulerProvider().io(), + TAG, + "deleteTagUnchecked"); } @Override public void deleteTagAndNotes(Tag tag) { - getCompositeDisposable() - .add( - getDataManager() - .deleteTagAndMoveNotesToTrash(tag) - .subscribeOn(getSchedulerProvider().io()) - .subscribe()); + DetachedWrites.run( + getDataManager().deleteTagAndMoveNotesToTrash(tag), + getSchedulerProvider().io(), + TAG, + "deleteTagAndNotes"); } @Override @@ -63,6 +67,7 @@ public int getCountNotesForTag() { public void setCountNotesForTag(int count) { this.countNotesForTag = count; + runOnView(view -> view.showNotesCount(count)); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DetachedWrites.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DetachedWrites.java new file mode 100644 index 00000000..dfb7f3d3 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/DetachedWrites.java @@ -0,0 +1,22 @@ +package com.pasich.mynotes.ui.presenter.dialogs; + +import android.util.Log; +import io.reactivex.Completable; +import io.reactivex.Scheduler; + +/** + * Runs a database write that a dialog starts right before it closes. + * + *

A dialog's presenter disposes its subscriptions when the dialog is dismissed, which happens in + * the same click that starts the write, so a write tied to them may be cancelled before the io + * thread picks it up. These writes are short, so they run to the end on their own. + */ +final class DetachedWrites { + + private DetachedWrites() {} + + @SuppressWarnings("ResultOfMethodCallIgnored") + static void run(Completable write, Scheduler io, String tag, String what) { + write.subscribeOn(io).subscribe(() -> {}, error -> Log.e(tag, what + " failed", error)); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/NameTagDialogPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/NameTagDialogPresenter.java index 39ec9c52..8ad07eb2 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/NameTagDialogPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/NameTagDialogPresenter.java @@ -1,6 +1,5 @@ package com.pasich.mynotes.ui.presenter.dialogs; -import android.util.Log; import com.pasich.mynotes.base.presenter.BasePresenter; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Tag; @@ -12,6 +11,8 @@ public class NameTagDialogPresenter extends BasePresenter implements NameTagDialogContract.presenter { + private static final String TAG = "NameTagDialogPresenter"; + @Inject public NameTagDialogPresenter( SchedulerProvider schedulerProvider, @@ -27,35 +28,16 @@ public void viewIsReady() { @Override public void saveTag(Tag nameTag) { - getCompositeDisposable() - .add( - getDataManager() - .addTag(nameTag) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - () -> {}, // onComplete - throwable -> - Log.e( - "NameTagDialogPresenter", - "Error saving tag", - throwable))); + DetachedWrites.run( + getDataManager().addTag(nameTag), getSchedulerProvider().io(), TAG, "saveTag"); } @Override public void editNameTag(String nameNewTag, Tag mTag) { - getCompositeDisposable() - .add( - getDataManager() - .renameTag(mTag, nameNewTag) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - () -> {}, // onComplete - throwable -> - Log.e( - "NameTagDialogPresenter", - "Error editing tag name", - throwable))); + DetachedWrites.run( + getDataManager().renameTag(mTag, nameNewTag), + getSchedulerProvider().io(), + TAG, + "editNameTag"); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index e114e301..6c0503a4 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -510,7 +510,7 @@ protected void onNewIntent(@NonNull Intent intent) { @Override public void startDeleteTagDialog(Tag tag) { - new DeleteTagDialog(tag).show(getSupportFragmentManager(), "deleteTag"); + DeleteTagDialog.newInstance(tag).show(getSupportFragmentManager(), "deleteTag"); } @Override @@ -1011,7 +1011,8 @@ public void deleteTag() { @Override public void renameTag() { - new NameTagDialog(tag).show(getSupportFragmentManager(), "RenameTag"); + NameTagDialog.newRenameInstance(tag) + .show(getSupportFragmentManager(), "RenameTag"); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/TagsActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/TagsActivity.java index 688b85e7..7fcd9242 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/TagsActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/TagsActivity.java @@ -28,7 +28,8 @@ import javax.inject.Inject; @AndroidEntryPoint -public class TagsActivity extends BaseActivity implements TagsContract.view, TagsSortView { +public class TagsActivity extends BaseActivity + implements TagsContract.view, TagsSortView, TagOptionsBottomSheet.TagOptionsListener { private ActivityTagsBinding binding; @@ -103,17 +104,18 @@ public void loadTags(List tags) { @Override public void showCreateTagDialog(int newPosition) { - new NameTagDialog(newPosition).show(getSupportFragmentManager(), "CreateTagDialog"); + NameTagDialog.newCreateInstance(newPosition) + .show(getSupportFragmentManager(), "CreateTagDialog"); } @Override public void showEditTagDialog(Tag tag) { - new NameTagDialog(tag).show(getSupportFragmentManager(), "RenameTag"); + NameTagDialog.newRenameInstance(tag).show(getSupportFragmentManager(), "RenameTag"); } @Override public void showDeleteTagDialog(Tag tag) { - new DeleteTagDialog(tag).show(getSupportFragmentManager(), "DeleteTagDialog"); + DeleteTagDialog.newInstance(tag).show(getSupportFragmentManager(), "DeleteTagDialog"); } @Override @@ -121,30 +123,27 @@ public void showTagOptionsDialog(Tag tag, View anchorView) { presenter.getTagNotesCount( tag, count -> { - TagOptionsBottomSheet bottomSheet = - new TagOptionsBottomSheet( - tag, - count, - new TagOptionsBottomSheet.TagOptionsListener() { - @Override - public void onDeleteTagClick(Tag tag) { - showDeleteTagDialog(tag); - } - - @Override - public void onRenameTagClick(Tag tag) { - showEditTagDialog(tag); - } - - @Override - public void onToggleVisibilityClick(Tag tag) { - presenter.toggleTagVisibility(tag); - } - }); - bottomSheet.show(getSupportFragmentManager(), "TagOptionsBottomSheet"); + if (getSupportFragmentManager().isStateSaved()) return; + TagOptionsBottomSheet.newInstance(tag, count) + .show(getSupportFragmentManager(), "TagOptionsBottomSheet"); }); } + @Override + public void onDeleteTagClick(Tag tag) { + showDeleteTagDialog(tag); + } + + @Override + public void onRenameTagClick(Tag tag) { + showEditTagDialog(tag); + } + + @Override + public void onToggleVisibilityClick(Tag tag) { + presenter.toggleTagVisibility(tag); + } + @Override public void showToastMessage(String message) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/DeleteTagDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/DeleteTagDialog.java index 00499330..3c843d14 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/DeleteTagDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/DeleteTagDialog.java @@ -24,8 +24,22 @@ public class DeleteTagDialog extends BaseDialogBottomSheets private Tag tag; private DialogDeleteTagBinding binding; - public DeleteTagDialog(Tag tag) { - this.tag = tag; + /** Used by the system to recreate the dialog; use {@link #newInstance} instead. */ + public DeleteTagDialog() {} + + public static DeleteTagDialog newInstance(@NonNull Tag tag) { + DeleteTagDialog dialog = new DeleteTagDialog(); + Bundle args = new Bundle(); + TagArgs.put(args, tag); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + tag = TagArgs.get(getArguments()); + if (tag == null) dismissAllowingStateLoss(); } @Nullable @@ -35,18 +49,20 @@ public View onCreateView( @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { binding = DialogDeleteTagBinding.inflate(getLayoutInflater()); + binding.title.headTextDialog.setText(R.string.deleteTag); + // Shown once the tag turns out to have notes. + binding.deleteTagAndNotes.setVisibility(View.GONE); + if (tag == null) return binding.getRoot(); mPresenter.attachView(this); mPresenter.viewIsReady(); mPresenter.getLoadCountNotesForTag(tag.getNameTag()); - binding.title.headTextDialog.setText(R.string.deleteTag); return binding.getRoot(); } @Override - public void onStart() { - super.onStart(); - binding.deleteTagAndNotes.setVisibility( - mPresenter.getCountNotesForTag() > 0 ? View.VISIBLE : View.GONE); + public void showNotesCount(int count) { + if (binding == null) return; + binding.deleteTagAndNotes.setVisibility(count > 0 ? View.VISIBLE : View.GONE); } @Override @@ -68,9 +84,17 @@ public void initListeners() { public void onDismiss(@NonNull DialogInterface dialog) { super.onDismiss(dialog); mPresenter.detachView(); - binding.deleteTag.setOnClickListener(null); - binding.deleteTagAndNotes.setOnClickListener(null); - binding.cancel.setOnClickListener(null); - tag = null; + if (binding != null) { + binding.deleteTag.setOnClickListener(null); + binding.deleteTagAndNotes.setOnClickListener(null); + binding.cancel.setOnClickListener(null); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mPresenter.detachView(); + binding = null; } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/NameTagDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/NameTagDialog.java index e4dc218f..3d3eee0f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/NameTagDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/NameTagDialog.java @@ -28,19 +28,48 @@ @AndroidEntryPoint public class NameTagDialog extends BaseDialogBottomSheets implements NameTagDialogContract.view { - private final Tag mTag; + private static final String ARG_NEW_POSITION = "newTagPosition"; + + private Tag mTag; @Inject public NameTagDialogPresenter mPresenter; private DialogNameTagBinding binding; private boolean errorText = true; private int newTagPosition = -1; - public NameTagDialog(int newPosition) { - this.mTag = null; - this.newTagPosition = newPosition; + /** + * Used by the system to recreate the dialog; use {@link #newCreateInstance} or {@link + * #newRenameInstance} instead. + */ + public NameTagDialog() {} + + /** A dialog that creates a tag at {@code newPosition}. */ + public static NameTagDialog newCreateInstance(int newPosition) { + NameTagDialog dialog = new NameTagDialog(); + Bundle args = new Bundle(); + args.putInt(ARG_NEW_POSITION, newPosition); + dialog.setArguments(args); + return dialog; + } + + /** A dialog that renames {@code tag}. */ + public static NameTagDialog newRenameInstance(@NonNull Tag tag) { + NameTagDialog dialog = new NameTagDialog(); + Bundle args = new Bundle(); + TagArgs.put(args, tag); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args = getArguments(); + mTag = TagArgs.get(args); + newTagPosition = args == null ? -1 : args.getInt(ARG_NEW_POSITION, -1); } - public NameTagDialog(Tag tag) { - this.mTag = tag; + private boolean isRename() { + return mTag != null; } @Nullable @@ -54,7 +83,7 @@ public View onCreateView( mPresenter.attachView(this); mPresenter.viewIsReady(); - if (getTag() != null && getTag().equals("RenameTag") && mTag != null) { + if (isRename()) { binding.nameTag.setText(mTag.getNameTag()); binding.outlinedTextField.setEndIconDrawable( AppCompatResources.getDrawable(requireContext(), R.drawable.ic_rename)); @@ -120,7 +149,7 @@ private void validateText(int length) { private void saveTag() { if (!errorText) { - if (getTag() != null && getTag().equals("RenameTag") && mTag != null) { + if (isRename()) { mPresenter.editNameTag( Objects.requireNonNull(binding.nameTag.getText()).toString(), mTag); } else { diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagArgs.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagArgs.java new file mode 100644 index 00000000..bd592be8 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagArgs.java @@ -0,0 +1,43 @@ +package com.pasich.mynotes.ui.view.dialogs.main; + +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.pasich.mynotes.data.model.Tag; + +/** + * Carries a {@link Tag} in a fragment's arguments, so a dialog about a tag can be rebuilt by the + * system after a rotation or after the process was killed. + */ +public final class TagArgs { + + private static final String KEY_ID = "tag.id"; + private static final String KEY_NAME = "tag.name"; + private static final String KEY_VISIBILITY = "tag.visibility"; + private static final String KEY_SYSTEM_ACTION = "tag.systemAction"; + private static final String KEY_POSITION = "tag.position"; + + private TagArgs() {} + + public static void put(@NonNull Bundle args, @NonNull Tag tag) { + args.putLong(KEY_ID, tag.getId()); + args.putString(KEY_NAME, tag.getNameTag()); + args.putInt(KEY_VISIBILITY, tag.getVisibility()); + args.putInt(KEY_SYSTEM_ACTION, tag.getSystemAction()); + args.putInt(KEY_POSITION, tag.getPosition()); + } + + /** Returns the tag put by {@link #put}, or {@code null} when there is none. */ + @Nullable + public static Tag get(@Nullable Bundle args) { + if (args == null || !args.containsKey(KEY_NAME)) return null; + Tag tag = new Tag(); + tag.id = args.getLong(KEY_ID); + String name = args.getString(KEY_NAME); + tag.setNameTag(name == null ? "" : name); + tag.setVisibility(args.getInt(KEY_VISIBILITY)); + tag.setSystemAction(args.getInt(KEY_SYSTEM_ACTION)); + tag.setPosition(args.getInt(KEY_POSITION)); + return tag; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagOptionsBottomSheet.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagOptionsBottomSheet.java index c15cf7cc..9bd6280a 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagOptionsBottomSheet.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/main/TagOptionsBottomSheet.java @@ -1,5 +1,6 @@ package com.pasich.mynotes.ui.view.dialogs.main; +import android.content.Context; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -12,17 +13,54 @@ import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.databinding.BottomSheetTagOptionsBinding; +/** + * Options for one tag. The host (the parent fragment or the activity) must implement {@link + * TagOptionsListener}; it is looked up on attach, so the sheet survives being recreated. + */ public class TagOptionsBottomSheet extends BaseDialogBottomSheets { + private static final String ARG_NOTES_COUNT = "notesCount"; + private BottomSheetTagOptionsBinding binding; - private final Tag tag; - private final TagOptionsListener listener; - private final int notesCount; - - public TagOptionsBottomSheet(Tag tag, int notesCount, TagOptionsListener listener) { - this.tag = tag; - this.notesCount = notesCount; - this.listener = listener; + private Tag tag; + @Nullable private TagOptionsListener listener; + private int notesCount; + + /** Used by the system to recreate the sheet; use {@link #newInstance} instead. */ + public TagOptionsBottomSheet() {} + + public static TagOptionsBottomSheet newInstance(@NonNull Tag tag, int notesCount) { + TagOptionsBottomSheet sheet = new TagOptionsBottomSheet(); + Bundle args = new Bundle(); + TagArgs.put(args, tag); + args.putInt(ARG_NOTES_COUNT, notesCount); + sheet.setArguments(args); + return sheet; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + if (getParentFragment() instanceof TagOptionsListener) { + listener = (TagOptionsListener) getParentFragment(); + } else if (context instanceof TagOptionsListener) { + listener = (TagOptionsListener) context; + } + } + + @Override + public void onDetach() { + super.onDetach(); + listener = null; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args = getArguments(); + tag = TagArgs.get(args); + notesCount = args == null ? 0 : args.getInt(ARG_NOTES_COUNT); + if (tag == null || listener == null) dismissAllowingStateLoss(); } @Nullable @@ -38,6 +76,7 @@ public View onCreateView( @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + if (tag == null || listener == null) return; setState((BottomSheetDialog) requireDialog()); vibrateOpenDialog(true); setupView(); @@ -61,17 +100,17 @@ private void setupView() { private void setupListeners() { binding.deleteTag.setOnClickListener(v -> { - listener.onDeleteTagClick(tag); + if (listener != null) listener.onDeleteTagClick(tag); dismiss(); }); binding.renameTag.setOnClickListener(v -> { - listener.onRenameTagClick(tag); + if (listener != null) listener.onRenameTagClick(tag); dismiss(); }); binding.visibleTag.setOnClickListener(v -> { - listener.onToggleVisibilityClick(tag); + if (listener != null) listener.onToggleVisibilityClick(tag); dismiss(); }); } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java b/app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java new file mode 100644 index 00000000..2b7bc656 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java @@ -0,0 +1,150 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.contract.dialogs.DeleteTagDialogContract; +import com.pasich.mynotes.ui.contract.dialogs.NameTagDialogContract; +import com.pasich.mynotes.ui.presenter.dialogs.DeleteTagDialogPresenter; +import com.pasich.mynotes.ui.presenter.dialogs.NameTagDialogPresenter; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.TestScheduler; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +public class TagDialogPresentersTest extends BasePresenterTest { + + @Mock DataManager dataManager; + @Mock DeleteTagDialogContract.view deleteView; + @Mock NameTagDialogContract.view nameView; + + private final TestScheduler io = new TestScheduler(); + + @Before + public void setUp() { + initMocks(this); + } + + /** io work waits for {@link #io} to be triggered, like a busy io pool. */ + private SchedulerProvider queuedIo() { + SchedulerProvider immediate = testSchedulerProvider(); + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return immediate.ui(); + } + + @Override + public Scheduler computation() { + return immediate.computation(); + } + + @Override + public Scheduler io() { + return io; + } + }; + } + + private static Completable flagging(AtomicBoolean ran) { + return Completable.fromAction(() -> ran.set(true)); + } + + @Test + public void deleteTag_dialogClosedBeforeTheWriteStarts_stillDeletes() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.clearTagInNotes(tag)).thenReturn(flagging(ran)); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.deleteTagUnchecked(tag); + presenter.detachView(); // the dialog dismisses in the same click + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } + + @Test + public void deleteTagAndNotes_dialogClosedBeforeTheWriteStarts_stillDeletes() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.deleteTagAndMoveNotesToTrash(tag)).thenReturn(flagging(ran)); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.deleteTagAndNotes(tag); + presenter.detachView(); + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } + + @Test + public void notesCount_arrivesLater_isShown() { + when(dataManager.getCountNotesTag("Work")).thenReturn(Single.just(3)); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter( + testSchedulerProvider(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.getLoadCountNotesForTag("Work"); + + verify(deleteView).showNotesCount(3); + } + + @Test + public void notesCount_failing_doesNotCrash() { + when(dataManager.getCountNotesTag("Work")) + .thenReturn(Single.error(new IllegalStateException("db closed"))); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter( + testSchedulerProvider(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.getLoadCountNotesForTag("Work"); + } + + @Test + public void saveTag_dialogClosedBeforeTheWriteStarts_stillSaves() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.addTag(tag)).thenReturn(flagging(ran)); + NameTagDialogPresenter presenter = + new NameTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(nameView); + + presenter.saveTag(tag); + presenter.detachView(); + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } + + @Test + public void renameTag_dialogClosedBeforeTheWriteStarts_stillRenames() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.renameTag(tag, "Home")).thenReturn(flagging(ran)); + NameTagDialogPresenter presenter = + new NameTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(nameView); + + presenter.editNameTag("Home", tag); + presenter.detachView(); + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java new file mode 100644 index 00000000..4d78ac17 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java @@ -0,0 +1,86 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.view.dialogs.main.DeleteTagDialog; +import com.pasich.mynotes.ui.view.dialogs.main.NameTagDialog; +import com.pasich.mynotes.ui.view.dialogs.main.TagArgs; +import com.pasich.mynotes.ui.view.dialogs.main.TagOptionsBottomSheet; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * After a rotation or process death the system rebuilds a dialog through its no-argument + * constructor and hands it the saved arguments; everything a dialog needs must be in them. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class TagDialogsRecreationTest { + + private static Tag tag() { + Tag tag = new Tag().create("Work"); + tag.id = 42; + tag.setVisibility(1); + tag.setPosition(3); + return tag; + } + + /** Rebuilds {@code original} the way the FragmentManager does. */ + private static Fragment recreate(Fragment original) { + Fragment fragment = + new FragmentFactory() + .instantiate( + TagDialogsRecreationTest.class.getClassLoader(), + original.getClass().getName()); + Bundle args = original.getArguments(); + fragment.setArguments(args == null ? null : new Bundle(args)); + return fragment; + } + + private static void assertSameTag(Tag actual) { + assertThat(actual).isNotNull(); + assertThat(actual.getId()).isEqualTo(42); + assertThat(actual.getNameTag()).isEqualTo("Work"); + assertThat(actual.getVisibility()).isEqualTo(1); + assertThat(actual.getPosition()).isEqualTo(3); + } + + @Test + public void deleteTagDialog_isRebuiltWithItsTag() { + Fragment fragment = recreate(DeleteTagDialog.newInstance(tag())); + + assertThat(fragment).isInstanceOf(DeleteTagDialog.class); + assertSameTag(TagArgs.get(fragment.getArguments())); + } + + @Test + public void renameTagDialog_isRebuiltWithItsTag() { + Fragment fragment = recreate(NameTagDialog.newRenameInstance(tag())); + + assertThat(fragment).isInstanceOf(NameTagDialog.class); + assertSameTag(TagArgs.get(fragment.getArguments())); + } + + @Test + public void createTagDialog_isRebuiltWithoutATag() { + Fragment fragment = recreate(NameTagDialog.newCreateInstance(5)); + + assertThat(fragment).isInstanceOf(NameTagDialog.class); + assertThat(TagArgs.get(fragment.getArguments())).isNull(); + } + + @Test + public void tagOptionsSheet_isRebuiltWithItsTag() { + Fragment fragment = recreate(TagOptionsBottomSheet.newInstance(tag(), 7)); + + assertThat(fragment).isInstanceOf(TagOptionsBottomSheet.class); + assertSameTag(TagArgs.get(fragment.getArguments())); + assertThat(fragment.requireArguments().getInt("notesCount")).isEqualTo(7); + } +} From fe7f03d2fe7e34fd3d8d7b7cca6270ba06531835 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:25:14 +0200 Subject: [PATCH 10/52] fix(sync): don't revive the tombstone of a note deleted under the editor 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. --- .../data/sync/SyncMutationCoordinator.java | 22 +++++++++--- .../sync/SyncMutationCoordinatorTest.java | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java index e2c84956..594bc7d9 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java @@ -426,11 +426,23 @@ private void assignInsertedNoteIds( /** * Stores the editor's content for a note, first keeping the content it replaces in the note's * history when that is due (see {@link NoteHistory#recordBeforeEdit}). + * + *

The note may be gone: a sync can apply another device's deletion while the editor is still + * open. The content UPDATE then matches no row, and touching the metadata anyway cleared the + * tombstone, leaving a live record with nothing behind it — the edit lost silently, and the + * remote deletion skipped forever because the record now looked newer. Re-inserting the note + * instead is not an option either: the deletion already removed its history and attachments. So + * nothing is written and the caller is told. + * + * @return false when the note no longer exists and nothing was stored. */ - public void updateNoteContent(@NonNull Note note) { - transactionExecutor.run(() -> { - noteHistory.recordBeforeEdit( - noteDao.getNoteSync(note.getId()), note, timeProvider.now()); + public boolean updateNoteContent(@NonNull Note note) { + return transactionExecutor.run(() -> { + Note stored = noteDao.getNoteSync(note.getId()); + if (stored == null) { + return false; + } + noteHistory.recordBeforeEdit(stored, note, timeProvider.now()); noteDao.updateNoteContent( note.getId(), note.getTitle(), @@ -440,7 +452,7 @@ public void updateNoteContent(@NonNull Note note) { note.getTag(), note.getAttachments()); touchRecord(SyncMetadata.RECORD_TYPE_NOTE, note.getId(), timeProvider.now()); - return null; + return true; }); } diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java index 38bee94a..36cc0bed 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java @@ -45,6 +45,13 @@ public void setUp() { taskCategoryDao = mock(TaskCategoryDao.class); transactions = mock(Transactions.class); syncMetadataDao = new FakeSyncMetadataDao(); + // Every note an edit names exists unless a test says otherwise. + when(noteDao.getNoteSync(org.mockito.ArgumentMatchers.anyInt())) + .thenAnswer(call -> { + Note stored = new Note().create("Stored", "", 1L, ""); + stored.setId(call.getArgument(0)); + return stored; + }); coordinator = new SyncMutationCoordinator( new SyncMutationCoordinator.TransactionExecutor() { @@ -205,6 +212,35 @@ public void updateNoteContent_reusesExistingStableIdAndTouchesMetadata() { assertThat(metadata.deletedAt).isNull(); } + @Test + public void updateNoteContent_leavesTheTombstoneOfANoteDeletedMeanwhile() { + // Another device's deletion was applied by a sync while the editor was open. The content + // UPDATE matches no row, and touching the metadata anyway cleared the tombstone: a live + // record with no note behind it, which then also outranked the remote deletion forever. + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 7L, "seed-stable", 900L, 900L)); + when(noteDao.getNoteSync(7)).thenReturn(null); + Note note = new Note().create("Late edit", "Body", 10L, ""); + note.setId(7); + + boolean stored = coordinator.updateNoteContent(note); + + assertThat(stored).isFalse(); + verify(noteDao, never()) + .updateNoteContent( + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + SyncMetadataEntity metadata = syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 7L); + assertThat(metadata.updatedAt).isEqualTo(900L); + assertThat(metadata.deletedAt).isEqualTo(900L); + } + @Test public void deleteNote_keepsTombstoneAndMarksDeletionTimestamp() { syncMetadataDao.insertIfAbsent( From 16924700d1f2460dc6b9449b706e2f5278534349 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:26:14 +0200 Subject: [PATCH 11/52] fix(attachments): attach huge and rotated photos without crashing or 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. --- .../mynotes/utils/file/ImageOptimizer.java | 120 ++++++++++++++++-- .../utils/file/ImageOptimizerTest.java | 105 +++++++++++++++ 2 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java diff --git a/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java b/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java index 275de8ef..932cbd61 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java +++ b/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java @@ -2,25 +2,45 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Matrix; import android.os.Build; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.exifinterface.media.ExifInterface; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; /** - * Ultra-lightweight image optimizer for Editor.js attachments. + * Lightweight image optimizer for Editor.js attachments. * - *

Designed specifically for: - byte[] → byte[] processing (no I/O inside) - very fast path (no - * resizing, no EXIF parsing, no metadata work) + *

Designed for byte[] → byte[] processing with no I/O inside. * *

Behavior: - Detects input type by magic bytes (JPEG / PNG / WEBP) - PNG with transparency → * stays PNG - PNG without transparency → converted to JPEG (smaller) - JPEG stays JPEG - WEBP stays * WEBP (WEBP_LOSSY on Android R+) - UNKNOWN formats (HEIC, GIF, PDF, TXT, ZIP, etc.) → returned * unchanged * + *

A photo longer than {@link #MAX_DIMENSION} on its long side is decoded subsampled, and the + * EXIF orientation is applied to the pixels, since re-encoding drops the EXIF block that told a + * viewer to turn the picture upright. + * *

Quality: - extraOptimize = true → strong compression (~60) - extraOptimize = false → normal * compression (~85) */ public class ImageOptimizer { + private static final String TAG = "ImageOptimizer"; + + /** + * The longest side a decoded image may have. + * + *

Decoding a 48–108 MP camera photo at full size takes 190–430 MB as ARGB_8888, more than + * the heap allows. An ordinary 12 MP photo (4000 px) still decodes at full size. + */ + static final int MAX_DIMENSION = 4096; + /** Detect format by magic bytes. */ public static OutFormat detectFormat(byte[] data) { if (data == null || data.length < 12) return OutFormat.UNKNOWN; @@ -48,10 +68,12 @@ public static OutFormat detectFormat(byte[] data) { *

PNG with alpha → stays PNG. PNG without alpha → JPEG. JPEG stays JPEG. WEBP stays WEBP * (lossy if Android R+). * - * @return optimized image bytes or original bytes on error. + * @return optimized image bytes, or the original bytes on any error, running out of memory + * included. */ public static byte[] optimizeRawImage( byte[] raw, OutFormat inputFormat, boolean extraOptimize) { + Bitmap bitmap = null; try { // We skip HEIC, GIF, PDF, DOC, ZIP — everything UNKNOWN if (inputFormat == OutFormat.UNKNOWN) { @@ -60,12 +82,35 @@ public static byte[] optimizeRawImage( int quality = extraOptimize ? 60 : 85; + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(raw, 0, raw.length, bounds); + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return raw; + BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + opts.inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, MAX_DIMENSION); - Bitmap bitmap = BitmapFactory.decodeByteArray(raw, 0, raw.length, opts); + bitmap = BitmapFactory.decodeByteArray(raw, 0, raw.length, opts); if (bitmap == null) return raw; + Matrix orientation = orientationMatrix(readOrientation(raw)); + if (orientation != null) { + Bitmap upright = + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.getWidth(), + bitmap.getHeight(), + orientation, + true); + if (upright != bitmap) { + bitmap.recycle(); + bitmap = upright; + } + } + OutFormat outFormat; if (inputFormat == OutFormat.PNG) { @@ -100,14 +145,73 @@ public static byte[] optimizeRawImage( } ByteArrayOutputStream bos = new ByteArrayOutputStream(); - bitmap.compress(fmt, quality, bos); - bitmap.recycle(); + if (!bitmap.compress(fmt, quality, bos)) return raw; return bos.toByteArray(); + } catch (Exception | OutOfMemoryError e) { + // Running out of memory is recoverable here: the bitmap is dropped and the original + // bytes, which are already in memory, are stored as they came. + Log.w(TAG, "Keeping the original image; it could not be optimized", e); + return raw; + } finally { + if (bitmap != null) { + bitmap.recycle(); + } + } + } + + /** + * The smallest power-of-two subsample (the only kind the decoder honours) that brings the long + * side to at most {@code maxDimension}. + */ + @VisibleForTesting + static int sampleSizeFor(int width, int height, int maxDimension) { + int longSide = Math.max(width, height); + int sample = 1; + while ((longSide + sample - 1) / sample > maxDimension) { + sample *= 2; + } + return sample; + } + + private static int readOrientation(@NonNull byte[] raw) { + try { + return new ExifInterface(new ByteArrayInputStream(raw)) + .getAttributeInt( + ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); } catch (Exception e) { - return raw; // fallback + return ExifInterface.ORIENTATION_NORMAL; + } + } + + /** + * The transform that turns a picture stored with this EXIF orientation upright, or {@code null} + * when it already is. + */ + @VisibleForTesting + @Nullable + static Matrix orientationMatrix(int orientation) { + Matrix matrix = new Matrix(); + switch (orientation) { + case ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1, 1); + case ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180); + case ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1, -1); + case ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90); + matrix.postScale(-1, 1); + } + case ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90); + case ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90); + matrix.postScale(-1, 1); + } + case ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90); + default -> { + return null; + } } + return matrix; } /** Allowed output formats */ diff --git a/app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java b/app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java new file mode 100644 index 00000000..2f12f679 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java @@ -0,0 +1,105 @@ +package com.pasich.mynotes.utils.file; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import androidx.exifinterface.media.ExifInterface; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.GraphicsMode; + +@RunWith(RobolectricTestRunner.class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = 35) +public class ImageOptimizerTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void sampleSize_leavesOrdinaryPhotosAlone() { + assertEquals(1, ImageOptimizer.sampleSizeFor(4000, 3000, 4096)); + assertEquals(1, ImageOptimizer.sampleSizeFor(4096, 100, 4096)); + } + + @Test + public void sampleSize_bringsHugePhotosUnderTheLimit() { + // 48 MP + assertEquals(2, ImageOptimizer.sampleSizeFor(8000, 6000, 4096)); + // 108 MP + assertEquals(4, ImageOptimizer.sampleSizeFor(12000, 9000, 4096)); + assertEquals(2, ImageOptimizer.sampleSizeFor(6000, 8192, 4096)); + assertEquals(4, ImageOptimizer.sampleSizeFor(6000, 8193, 4096)); + } + + @Test + public void orientationMatrix_isNullOnlyForUprightPictures() { + assertNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_NORMAL)); + assertNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_UNDEFINED)); + assertNotNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_ROTATE_90)); + assertNotNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_TRANSVERSE)); + } + + @Test + public void rotatedJpeg_isStoredUpright() throws Exception { + byte[] raw = jpeg(40, 20, ExifInterface.ORIENTATION_ROTATE_90); + + byte[] out = ImageOptimizer.optimizeRawImage(raw, ImageOptimizer.OutFormat.JPEG, false); + + BitmapFactory.Options bounds = decodeBounds(out); + assertEquals(20, bounds.outWidth); + assertEquals(40, bounds.outHeight); + } + + @Test + public void uprightJpeg_keepsItsSize() throws Exception { + byte[] raw = jpeg(40, 20, ExifInterface.ORIENTATION_NORMAL); + + byte[] out = ImageOptimizer.optimizeRawImage(raw, ImageOptimizer.OutFormat.JPEG, true); + + BitmapFactory.Options bounds = decodeBounds(out); + assertEquals(40, bounds.outWidth); + assertEquals(20, bounds.outHeight); + } + + @Test + public void undecodableBytes_comeBackUnchanged() { + byte[] raw = new byte[] {(byte) 0xFF, (byte) 0xD8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + + assertEquals( + raw, ImageOptimizer.optimizeRawImage(raw, ImageOptimizer.OutFormat.JPEG, false)); + } + + private byte[] jpeg(int width, int height, int orientation) throws Exception { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.eraseColor(Color.RED); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, bos); + File file = tmp.newFile("photo.jpg"); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(bos.toByteArray()); + } + ExifInterface exif = new ExifInterface(file.getAbsolutePath()); + exif.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(orientation)); + exif.saveAttributes(); + return Files.readAllBytes(file.toPath()); + } + + private static BitmapFactory.Options decodeBounds(byte[] data) { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, 0, data.length, bounds); + return bounds; + } +} From 3bb6c200e6c8e97a921b3051384d5c92542e0cdd Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:26:46 +0200 Subject: [PATCH 12/52] fix(editor): show markup in a plain-text note as text, not as HTML plainTextBlocks put each line into a paragraph unescaped, and the paragraph tool renders it with innerHTML, so a shared note such as 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. --- app/src/main/assets/editor/js/runtime.min.js | 2 +- .../extendedEditor/NoteEditorView.java | 8 ++++-- notes_editor/src/custom/runtime.js | 10 ++++++- notes_editor/test/save.test.mjs | 28 +++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/src/main/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js index 0dcb0edc..91fceb75 100644 --- a/app/src/main/assets/editor/js/runtime.min.js +++ b/app/src/main/assets/editor/js/runtime.min.js @@ -2,4 +2,4 @@ * My Notes — Editor Runtime Build * (c) 2025 pasichDev. Editor.js — MIT */ -!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){t&&t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t))}function flushContent(){return t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let s=0;function reportViewStateNow(){clearTimeout(i),s=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-s>=250&&(s=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const s=window.getSelection();s.removeAllRanges(),s.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const c=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=a.undo.length>0||a.pending,e=a.redo.length>0&&!a.pending,n=`${t}|${e}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=a.epoch;readDocument().then(t=>{o===a.epoch&&(a.current=historyEntry(t,null),n&&(a.settleUntil=Date.now()+600),function(){const t=u;if(u=null,t?.current&&t.current.json===a.current?.json)try{a.undo=(t.undo||[]).map(importedEntry).slice(-c),a.redo=(t.redo||[]).map(importedEntry).slice(-c),a.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n){if(!a.current||a.applying||e)return;if(n&&Date.now()=3e3;a.timer=setTimeout(captureHistory,t||o?0:700),reportHistory()}function historyInputStarting(){!a.current||a.applying||e||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(a.timer),a.timer=null,!t||!a.current||!a.pending&&!e)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(t=>{if(n!==a.epoch||!a.current)return;const e=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(historyDoc(a.current),t),a.undo.push({...a.current,caret:e||a.current.caret}),a.undo.length>c&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,e.caret),a.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,s,c),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(t){return a.queue=a.queue.then(t).catch(t=>console.error("[History]",t)),a.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.undo.pop();if(t&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.redo.pop();if(t&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange("insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(a.batchHasText||/\S/.test(e)),!1),/\S/.test(e)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});function plainTextBlocks(t){const e=t.split("\n");for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const f=new Map;let p=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=f.get(t);n&&(f.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);a.current&&(a.current=strip(a.current));const e=distinct(a.undo.map(strip));a.current&&e.length&&e[e.length-1].json===a.current.json&&e.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=e,a.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const s=[...i.querySelectorAll("[contenteditable]")],c=s.find(t=>t.contains(o));if(!c)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(c),a.setEnd(o,n.startOffset),{id:l.id,input:s.indexOf(c),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const t=a.undo.map(exportedEntry),e=a.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=a.current.json.length+size(t)+size(e);for(;n>d&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); +!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){t&&t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t))}function flushContent(){return t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let c=0;function reportViewStateNow(){clearTimeout(i),c=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-c>=250&&(c=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const s=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=a.undo.length>0||a.pending,e=a.redo.length>0&&!a.pending,n=`${t}|${e}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=a.epoch;readDocument().then(t=>{o===a.epoch&&(a.current=historyEntry(t,null),n&&(a.settleUntil=Date.now()+600),function(){const t=u;if(u=null,t?.current&&t.current.json===a.current?.json)try{a.undo=(t.undo||[]).map(importedEntry).slice(-s),a.redo=(t.redo||[]).map(importedEntry).slice(-s),a.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n){if(!a.current||a.applying||e)return;if(n&&Date.now()=3e3;a.timer=setTimeout(captureHistory,t||o?0:700),reportHistory()}function historyInputStarting(){!a.current||a.applying||e||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(a.timer),a.timer=null,!t||!a.current||!a.pending&&!e)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(t=>{if(n!==a.epoch||!a.current)return;const e=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(historyDoc(a.current),t),a.undo.push({...a.current,caret:e||a.current.caret}),a.undo.length>s&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,e.caret),a.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,c,s),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(t){return a.queue=a.queue.then(t).catch(t=>console.error("[History]",t)),a.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.undo.pop();if(t&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.redo.pop();if(t&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange("insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(a.batchHasText||/\S/.test(e)),!1),/\S/.test(e)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});function escapeHtml(t){return t.replace(/&/g,"&").replace(//g,">")}function plainTextBlocks(t){const e=t.split("\n").map(escapeHtml);for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const f=new Map;let p=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=f.get(t);n&&(f.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);a.current&&(a.current=strip(a.current));const e=distinct(a.undo.map(strip));a.current&&e.length&&e[e.length-1].json===a.current.json&&e.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=e,a.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(t=>t.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const t=a.undo.map(exportedEntry),e=a.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=a.current.json.length+size(t)+size(e);for(;n>d&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java index fe403257..2c039884 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java @@ -164,9 +164,13 @@ public void setAutofocus(boolean autofocus) { private void setupWebView() { WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); - webSettings.setAllowFileAccess(true); + // The page and its scripts come from file:///android_asset, which this setting does not + // govern, and attachments through the editorjs:// interceptor: no other file:// URL is + // needed, so a note's content cannot read the app's private files. Nothing is loaded over + // the network either, so mixed content is never allowed. + webSettings.setAllowFileAccess(false); webSettings.setDomStorageEnabled(true); - webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); + webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); webSettings.setGeolocationEnabled(false); webSettings.setAllowUniversalAccessFromFileURLs(false); webSettings.setOffscreenPreRaster(true); diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index fafe4e52..91275653 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -856,8 +856,16 @@ function setThemeColors (colors) { const PLAIN_TEXT_LINE_BLOCKS = 200 const PLAIN_TEXT_BLOCK_CHARS = 1000 +/** + * Plain text as paragraph HTML. The paragraph tool shows its text as HTML, so markup in a note + * (shared into the app, say) would otherwise run in the page, which can reach Android. + */ +function escapeHtml (text) { + return text.replace(/&/g, '&').replace(//g, '>') +} + function plainTextBlocks (text) { - const lines = text.split('\n') + const lines = text.split('\n').map(escapeHtml) while (lines.length && lines[0].trim() === '') lines.shift() while (lines.length && lines[lines.length - 1].trim() === '') lines.pop() const textLines = lines.filter(line => line.trim() !== '').length diff --git a/notes_editor/test/save.test.mjs b/notes_editor/test/save.test.mjs index 0dbd4a89..177af3a3 100644 --- a/notes_editor/test/save.test.mjs +++ b/notes_editor/test/save.test.mjs @@ -114,3 +114,31 @@ test('a very long plain-text note opens in blocks of about a kilobyte that give 'no block starts or ends with an empty line, which the editor could drop') } }) + +test('markup in a plain-text note is shown as text, never run as HTML', () => { + const page = openPage() + const text = '\n\nList a && b > c' + + const blocks = page.window.plainTextBlocks(text) + + assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), [ + '<img src=x onerror="Android.x()">', + '
List<String> a && b > c' + ]) + const decode = html => + html.replace(/
/g, '\n').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&') + assert.equal(blocks.map(b => decode(b.data.text)).join('\n'), text) +}) + +test('markup is escaped in a very long plain-text note too', () => { + const page = openPage() + const lines = [] + for (let i = 0; i < 300; i++) lines.push(`line ${i} & more`) + + const blocks = page.window.plainTextBlocks(lines.join('\n')) + + assert.ok(blocks.length > 1) + for (const block of blocks) { + assert.ok(!/<(?!br>)/.test(block.data.text), 'no tag other than
is left') + } +}) From 44fc8fc8862ebe3a63ed3edfe4eabb88147574ba Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:26:50 +0200 Subject: [PATCH 13/52] fix(editor): decode HTML entities when a rich note becomes plain text cleanText only decoded  , so text like "a < b && c" came back from the extended editor as "a < b && c", and header text was not cleaned at all. Decode entities in one pass for paragraphs, headers and list items. --- .../extendedEditor/utils/EditorJsonUtils.java | 68 +++++++++++++++---- .../utils/PlainTextBlocksRoundTripTest.java | 31 +++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJsonUtils.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJsonUtils.java index 4f5d604f..58f9bc9b 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJsonUtils.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJsonUtils.java @@ -5,6 +5,8 @@ import com.pasich.mynotes.extendedEditor.models.EditorAttachment; import com.pasich.mynotes.extendedEditor.models.ParsedNote; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -134,7 +136,7 @@ public static ParsedNote extendedNoteToOldNote(String jsonData) { case "header": case "Headers": - plainText.append(data.optString("text", "")); + plainText.append(cleanText(data.optString("text", ""))); break; case "list": @@ -269,24 +271,66 @@ private static void appendListItem( } } + // The entities the editor writes into block HTML: what the page escapes (& < > and, in + // attributes, quotes), non-breaking spaces, and numeric references. + private static final Pattern ENTITY = + Pattern.compile("&(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|amp|lt|gt|quot|apos|nbsp);"); + /** - * Cleans HTML-formatted text extracted from Editor.js blocks and converts it into plain text. - * - *

Operations performed: - Converts
- * tags (any variant) into newline characters - Replaces   with a normal space - Removes - * all remaining HTML tags using a regex - * - *

This method ensures that paragraph and header text from the rich-text editor is safely - * converted into a readable plain-text form. + * Turns the HTML of a paragraph, header or list item into plain text: line breaks become + * newlines, tags are dropped and entities are decoded, so text such as {@code a < b && c} comes + * back as it was typed and not as {@code a < b && c}. * * @param text Raw HTML/text content from Editor.js * @return Sanitized plain-text string */ - private static String cleanText(String text) { + static String cleanText(String text) { if (text == null) return ""; text = text.replaceAll("(?i)", "\n"); - text = text.replace(" ", " "); text = text.replaceAll("<[^>]+>", ""); - return text; + return decodeEntities(text); + } + + /** Decodes entities in one pass, so {@code &lt;} gives {@code <} and not {@code <}. */ + private static String decodeEntities(String text) { + if (text.indexOf('&') < 0) return text; + Matcher matcher = ENTITY.matcher(text); + StringBuilder out = new StringBuilder(text.length()); + int last = 0; + while (matcher.find()) { + out.append(text, last, matcher.start()); + out.append(decodeEntity(matcher.group(1), matcher.group())); + last = matcher.end(); + } + out.append(text, last, text.length()); + return out.toString(); + } + + private static String decodeEntity(String name, String original) { + switch (name) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return "\""; + case "apos": + return "'"; + case "nbsp": + return " "; + default: + try { + boolean hex = name.charAt(1) == 'x' || name.charAt(1) == 'X'; + int code = Integer.parseInt(name.substring(hex ? 2 : 1), hex ? 16 : 10); + if (code == 0xA0) return " "; + return Character.isValidCodePoint(code) + ? new String(Character.toChars(code)) + : original; + } catch (NumberFormatException e) { + return original; + } + } } } diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java index 722a8d2c..6bb10e13 100644 --- a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java @@ -39,4 +39,35 @@ public void aBlockPerLineGivesBackTheSameText() throws Exception { assertThat(plain).isEqualTo(text); } + + /** + * The page escapes markup in a plain-text note (plainTextBlocks), so it is shown as text; the + * way back decodes it again, for paragraphs and headers alike. + */ + @Test + public void escapedMarkupComesBackAsTyped() throws Exception { + String text = "List a && b\n\nx > 1 & y\nTitle

"; + JSONArray blocks = + new JSONArray() + .put(paragraph("List<String> a && b")) + .put(paragraph("
x > 1 &amp; y")) + .put( + new JSONObject() + .put("type", "header") + .put( + "data", + new JSONObject() + .put("text", "Title <h1>") + .put("level", 2))); + + String plain = EditorJsonUtils.extendedNoteToOldNote(blocks.toString()).plainText; + + assertThat(plain).isEqualTo(text); + } + + @Test + public void nonBreakingSpacesAndNumericReferencesAreDecoded() { + assertThat(EditorJsonUtils.cleanText("a b cA'")).isEqualTo("a b cA'"); + assertThat(EditorJsonUtils.cleanText("&unknown; & &#xZZ;")).isEqualTo("&unknown; & &#xZZ;"); + } } From 0054a26933cac8861e347c4a843d0316bd25616b Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:26:59 +0200 Subject: [PATCH 14/52] fix(notes): let the note sheet's writes and Copy outlive the sheet 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. --- .../dialogs/MoreNoteDialogContract.java | 9 +- .../dialogs/MoreNoteDialogPresenter.java | 90 +++++++++---------- .../ui/view/dialogs/MoreNoteDialog.java | 17 ++-- .../MoreNoteDialogPresenterTest.java | 77 ++++++++++++++++ 4 files changed, 132 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/MoreNoteDialogContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/MoreNoteDialogContract.java index d0f1b096..ed7a7a0f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/MoreNoteDialogContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/dialogs/MoreNoteDialogContract.java @@ -8,13 +8,16 @@ public interface MoreNoteDialogContract { + /** Receives the id of a note's copy, on the main thread. */ + interface OnNoteCopied { + void onCopied(long newNoteId); + } + interface view extends BaseView { void setSliderValue(int value); void initInterfaces(); - void callableCopyNote(long newNoteId); - void createChipsTag(List tags); void close(); @@ -33,7 +36,7 @@ interface presenter extends BasePresenter { void editTagNote(String nameTag, int idNote); - void copyNoteMainActivity(); + void copyNoteMainActivity(OnNoteCopied onCopied); void requestTagsOneShot(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/MoreNoteDialogPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/MoreNoteDialogPresenter.java index a1ba0c65..77646a08 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/MoreNoteDialogPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/dialogs/MoreNoteDialogPresenter.java @@ -65,15 +65,12 @@ public void loadNote(int noteId) { @Override public void noteMoveToTrash() { - getCompositeDisposable() - .add( - getDataManager() - .moveNoteToTrash(mNote.getId()) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - () -> {}, // onComplete - throwable -> Log.e(TAG, "Error deleting note", throwable))); + if (mNote == null) return; + DetachedWrites.run( + getDataManager().moveNoteToTrash(mNote.getId()), + getSchedulerProvider().io(), + TAG, + "noteMoveToTrash"); } @Override @@ -83,44 +80,43 @@ public void editSizeText(int value) { @Override public void removeTagNote(int idNote) { - getCompositeDisposable() - .add( - getDataManager() - .setTagNote("", idNote) - .subscribeOn(getSchedulerProvider().io()) - .subscribe( - () -> {}, // onComplete - throwable -> Log.e(TAG, "Error removing tag", throwable))); + DetachedWrites.run( + getDataManager().setTagNote("", idNote), + getSchedulerProvider().io(), + TAG, + "removeTagNote"); } @Override public void editTagNote(String nameTag, int idNote) { - getCompositeDisposable() - .add( - getDataManager() - .setTagNote(nameTag, idNote) - .subscribeOn(getSchedulerProvider().io()) - .subscribe( - () -> {}, // onComplete - throwable -> Log.e(TAG, "Error editing tag", throwable))); + DetachedWrites.run( + getDataManager().setTagNote(nameTag, idNote), + getSchedulerProvider().io(), + TAG, + "editTagNote"); } + /** + * Copies the note and hands the copy's id to {@code onCopied} on the main thread. The copy is + * not tied to the dialog, which closes before the copy is written. + */ @Override - public void copyNoteMainActivity() { - getCompositeDisposable() - .add( - getDataManager() - .addNote( - new Note() - .create( - mNote.getTitle() + " (copy)", - mNote.getValue() + " ", - new Date().getTime(), - mNote.getTag())) - .subscribeOn(getSchedulerProvider().io()) - .subscribe( - aLong -> getView().callableCopyNote(aLong), - throwable -> Log.e(TAG, "Error copying note", throwable))); + @SuppressWarnings("ResultOfMethodCallIgnored") + public void copyNoteMainActivity(MoreNoteDialogContract.OnNoteCopied onCopied) { + if (mNote == null) return; + getDataManager() + .addNote( + new Note() + .create( + mNote.getTitle() + " (copy)", + mNote.getValue() + " ", + new Date().getTime(), + mNote.getTag())) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + onCopied::onCopied, + throwable -> Log.e(TAG, "Error copying note", throwable)); } @Override @@ -128,15 +124,11 @@ public void togglePinNote() { if (mNote == null) return; boolean newPin = !mNote.isPinned(); mNote.setPinned(newPin); - getCompositeDisposable() - .add( - getDataManager() - .setPinNote(mNote.getId(), newPin) - .subscribeOn(getSchedulerProvider().io()) - .subscribe( - () -> {}, - throwable -> - Log.e(TAG, "togglePinNote failed", throwable))); + DetachedWrites.run( + getDataManager().setPinNote(mNote.getId(), newPin), + getSchedulerProvider().io(), + TAG, + "togglePinNote"); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java index 9e88dd7f..5c2dd968 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java @@ -211,13 +211,6 @@ public void initInterfaces() { dismiss(); } - @Override - public void callableCopyNote(long newNoteId) { - if (mainActivity != null) { - mainActivity.openCopyNote(Math.toIntExact(newNoteId)); - } - } - @Override public void initListeners() { if (mPresenter.getNote() == null) { @@ -304,8 +297,14 @@ public void onStopTrackingTouch(@NonNull Slider slider) { }); binding.copyNote.setOnClickListener(v -> { - if (mPresenter.getNote().isAttachments()) return; - mPresenter.copyNoteMainActivity(); + if (mPresenter.getNote().isAttachments() || mainActivity == null) return; + // The sheet is gone by the time the copy is written; the activity opens it. + MoreNoteMainActivityView host = mainActivity; + Activity hostActivity = requireActivity(); + mPresenter.copyNoteMainActivity(newNoteId -> { + if (hostActivity.isFinishing() || hostActivity.isDestroyed()) return; + host.openCopyNote(newNoteId); + }); dismiss(); }); diff --git a/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java index 6cf7c1af..992832cb 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java @@ -1,13 +1,24 @@ package com.pasich.mynotes.presenter; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.pasich.mynotes.base.BasePresenterTest; import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.ui.contract.dialogs.MoreNoteDialogContract; import com.pasich.mynotes.ui.presenter.dialogs.MoreNoteDialogPresenter; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.TestScheduler; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -45,4 +56,70 @@ public void viewIsReady_textSizeAboveSliderRange_usesMaximumSliderValue() { verify(view).setSliderValue(32); } + + private void loadNote(MoreNoteDialogPresenter target) { + Note note = new Note().create("Title", "Text", 1L, ""); + note.setId(5); + when(dataManager.getNoteForId(5)).thenReturn(Single.just(note)); + target.loadNote(5); + } + + /** A presenter whose io work waits for {@code io} to be triggered. */ + private MoreNoteDialogPresenter presenterWithQueuedIo(TestScheduler io) { + SchedulerProvider immediate = testSchedulerProvider(); + SchedulerProvider queued = + new SchedulerProvider() { + @Override + public Scheduler ui() { + return immediate.ui(); + } + + @Override + public Scheduler computation() { + return immediate.computation(); + } + + @Override + public Scheduler io() { + return io; + } + }; + MoreNoteDialogPresenter target = + new MoreNoteDialogPresenter(queued, new CompositeDisposable(), dataManager); + target.attachView(view); + return target; + } + + @Test + public void togglePin_dialogClosedBeforeTheWriteStarts_stillPins() { + TestScheduler io = new TestScheduler(); + MoreNoteDialogPresenter target = presenterWithQueuedIo(io); + loadNote(target); + io.triggerActions(); + AtomicBoolean ran = new AtomicBoolean(); + when(dataManager.setPinNote(5, true)) + .thenReturn(Completable.fromAction(() -> ran.set(true))); + + target.togglePinNote(); + target.detachView(); // the sheet dismisses in the same click + io.triggerActions(); + + assertTrue(ran.get()); + } + + @Test + public void copy_dialogClosedBeforeTheCopyIsWritten_stillOpensTheCopy() { + TestScheduler io = new TestScheduler(); + MoreNoteDialogPresenter target = presenterWithQueuedIo(io); + loadNote(target); + io.triggerActions(); + when(dataManager.addNote(any())).thenReturn(Single.just(77L)); + AtomicLong opened = new AtomicLong(); + + target.copyNoteMainActivity(opened::set); + target.detachView(); + io.triggerActions(); + + assertEquals(77L, opened.get()); + } } From ba36e4b280b55530e1c7a34e7f8e63d13e3c8f17 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:27:33 +0200 Subject: [PATCH 15/52] fix(sync): trim the oldest settled conflicts, not arbitrary ones 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. --- .../data/database/dao/SyncConflictDao.java | 30 +++- .../mynotes/data/sync/RoomSyncStore.java | 9 ++ .../pasich/mynotes/data/sync/SyncService.java | 40 +++-- .../sync/SettledConflictRetentionTest.java | 143 ++++++++++++++++++ 4 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncConflictDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncConflictDao.java index 8c5fb51f..47218b04 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncConflictDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncConflictDao.java @@ -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. + * + *

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 getResolvedVersionIds(); @Query("SELECT * FROM sync_conflicts WHERE id = :id LIMIT 1") @@ -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. + * + *

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); diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index 21058013..1751d0ee 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -56,6 +56,13 @@ public final class RoomSyncStore implements SyncStore { private static final String PREFS = "sync_state"; private static final String LEGACY_STATE = "last_state"; private static final String PREFERENCES_HASH = "preferences_hash"; + + /** + * Settled conflicts kept, newest first. Each names two versions, so this matches the number of + * settled versions {@link SyncService} publishes; older rows could never be published anyway. + */ + static final int MAX_KEPT_RESOLVED_CONFLICTS = SyncService.MAX_PUBLISHED_SETTLED_IDS / 2; + private static final String PREFERENCES_STABLE_ID = "00000000-0000-4000-8000-000000000000"; private static final String ATTACHMENT_CACHE_DIR = "sync-attachments"; private static final String HASH_CACHE_FILE = "hash-cache.json"; @@ -1242,6 +1249,7 @@ public void resolveConflict(long conflictId, @NonNull SyncResolution resolution) throw new SyncRuntimeException(error); } database.syncConflictDao().markResolved(conflictId, resolution.name(), resolvedAt); + database.syncConflictDao().deleteResolvedBeyond(MAX_KEPT_RESOLVED_CONFLICTS); }); } catch (SyncRuntimeException error) { notesToReconcile.clear(); @@ -1411,6 +1419,7 @@ private void finalizeResolvedPreferencesConflict(long conflictId, @NonNull Strin null); } database.syncConflictDao().markResolved(conflictId, resolution, resolvedAt); + database.syncConflictDao().deleteResolvedBeyond(MAX_KEPT_RESOLVED_CONFLICTS); }); } diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java index f53a3db6..e6fe69e8 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java @@ -32,7 +32,7 @@ public final class SyncService { private static final long MAX_TOLERATED_CLOCK_SKEW_MILLIS = 24L * 60L * 60L * 1000L; /** Well under the schema record limit, so a bundle can always still be published. */ - private static final int MAX_PUBLISHED_SETTLED_IDS = 2_000; + static final int MAX_PUBLISHED_SETTLED_IDS = 2_000; private final SyncStore store; private final SyncMerger merger; @@ -151,9 +151,9 @@ private SyncState syncExclusively(@NonNull SyncBackend backend) { // A choice the user already made must never be offered again, wherever it was made. java.util.Set settledVersionIds = - new java.util.LinkedHashSet<>(store.getResolvedAlternativeIds()); - settledVersionIds.addAll(remoteResult.getResolvedAlternativeIds()); - settledVersionIds = capSettledVersionIds(settledVersionIds); + capSettledVersionIds( + store.getResolvedAlternativeIds(), + remoteResult.getResolvedAlternativeIds()); java.util.List allConflicts = new java.util.ArrayList<>(); for (SyncMergeResult.Conflict conflict : remoteResult.getConflicts()) { @@ -262,24 +262,36 @@ private void warnAboutClockSkew(@NonNull SyncSnapshot remote) { * *

Every resolution adds its two version identities and they were never dropped, so the array * grew for the life of the account. Past the schema's record limit {@code encode} refuses the - * bundle and every publish fails permanently, with nothing the user can do about it. Trimming - * preserves the identities this device settled most recently; the worst case for a dropped one - * is that an already-settled conflict is offered again, which is recoverable, whereas a bundle - * that cannot be published is not. + * bundle and every publish fails permanently, with nothing the user can do about it. The worst + * case for a dropped identity is that an already-settled conflict is offered again, which is + * recoverable, whereas a bundle that cannot be published is not — so the oldest go first. + * + *

The store's own settlements come newest first, so it is the oldest of them that go. The + * bundle carries no settlement time, so the remote ones come in wire order; they are + * interleaved with the local ones, so neither this device's own settlements nor everyone else's + * can crowd the other side out entirely. */ @NonNull - private static java.util.Set capSettledVersionIds( - @NonNull java.util.Set settled) { - if (settled.size() <= MAX_PUBLISHED_SETTLED_IDS) { - return settled; + static java.util.Set capSettledVersionIds( + @NonNull java.util.Collection local, + @NonNull java.util.Collection remote) { + java.util.Set merged = new java.util.LinkedHashSet<>(); + java.util.Iterator localIds = local.iterator(); + java.util.Iterator remoteIds = remote.iterator(); + while (localIds.hasNext() || remoteIds.hasNext()) { + if (localIds.hasNext()) merged.add(localIds.next()); + if (remoteIds.hasNext()) merged.add(remoteIds.next()); + } + if (merged.size() <= MAX_PUBLISHED_SETTLED_IDS) { + return merged; } Log.w( TAG, "Trimming " - + settled.size() + + merged.size() + " settled conflict versions to the publishable limit"); java.util.Set trimmed = new java.util.LinkedHashSet<>(); - for (String versionId : settled) { + for (String versionId : merged) { if (trimmed.size() >= MAX_PUBLISHED_SETTLED_IDS) { break; } diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java new file mode 100644 index 00000000..f98ca130 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java @@ -0,0 +1,143 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import androidx.room.Room; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.dao.SyncConflictDao; +import com.pasich.mynotes.data.database.entities.SyncConflictEntity; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * Settled conflicts are bounded by age, not by whatever order SQLite happens to return them in. + * + *

The bundle carries a capped number of settled versions. The query feeding that cap was a plain + * UNION, so which versions survived it was arbitrary, and settled rows were never deleted. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SettledConflictRetentionTest { + + private AppDatabase db; + private SyncConflictDao dao; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + dao = db.syncConflictDao(); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void resolvedVersionIdsComeNewestFirst() { + // Inserted in an order unrelated to their settlement time or their ids. + insertResolved("b", 200L); + insertResolved("c", 300L); + insertResolved("a", 100L); + insertOpen("z"); + + List ids = dao.getResolvedVersionIds(); + + assertThat(ids).hasSize(6); + assertThat(ids.subList(0, 2)).containsExactly(id("c", 'w'), id("c", 'l')); + assertThat(ids.subList(2, 4)).containsExactly(id("b", 'w'), id("b", 'l')); + assertThat(ids.subList(4, 6)).containsExactly(id("a", 'w'), id("a", 'l')); + } + + @Test + public void deleteResolvedBeyondKeepsOnlyTheNewestSettlementsAndEveryOpenConflict() { + insertResolved("a", 100L); + insertResolved("b", 200L); + insertResolved("c", 300L); + insertOpen("z"); + + dao.deleteResolvedBeyond(2); + + assertThat(dao.getResolvedVersionIds()) + .containsExactly(id("c", 'w'), id("c", 'l'), id("b", 'w'), id("b", 'l')); + assertThat(dao.getUnresolvedCount()).isEqualTo(1); + } + + @Test + public void capSettledVersionIdsDropsTheOldestLocalSettlements() { + List newestFirst = new ArrayList<>(); + for (int index = 0; index < SyncService.MAX_PUBLISHED_SETTLED_IDS + 50; index++) { + newestFirst.add(String.format("%064x", index)); + } + + Set kept = SyncService.capSettledVersionIds(newestFirst, Collections.emptyList()); + + assertThat(kept) + .containsExactlyElementsIn( + newestFirst.subList(0, SyncService.MAX_PUBLISHED_SETTLED_IDS)); + } + + @Test + public void capSettledVersionIdsKeepsSomeOfEachSide() { + List local = new ArrayList<>(); + List remote = new ArrayList<>(); + for (int index = 0; index < SyncService.MAX_PUBLISHED_SETTLED_IDS; index++) { + local.add(String.format("a%063x", index)); + remote.add(String.format("b%063x", index)); + } + + Set kept = SyncService.capSettledVersionIds(local, remote); + + assertThat(kept).hasSize(SyncService.MAX_PUBLISHED_SETTLED_IDS); + assertThat(kept).contains(local.get(0)); + assertThat(kept).contains(remote.get(0)); + assertThat(kept).doesNotContain(local.get(local.size() - 1)); + } + + private void insertResolved(String name, long resolvedAt) { + dao.insertIgnoringDuplicates(Collections.singletonList(row(name, true, resolvedAt))); + } + + private void insertOpen(String name) { + dao.insertIgnoringDuplicates(Collections.singletonList(row(name, false, 0L))); + } + + private static String id(String name, char side) { + return name + side; + } + + private static SyncConflictEntity row(String name, boolean resolved, long resolvedAt) { + return new SyncConflictEntity( + "note", + "stable-" + name, + "pair-" + name, + "LOCAL", + "REMOTE", + id(name, 'w'), + id(name, 'l'), + "{}", + "{}", + 1L, + 1L, + false, + false, + resolved ? "KEEP_WINNER" : "PENDING", + resolved, + 1L, + resolvedAt); + } +} From dcfa021211c04edc740abdab20ea0934d865fa01 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:28:18 +0200 Subject: [PATCH 16/52] fix(export): keep long PDF paragraphs and overlong words on the page 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. --- .../mynotes/utils/file/FileExportUtils.java | 237 +++++++++++------- .../mynotes/utils/file/PdfLineWrapTest.java | 60 +++++ 2 files changed, 206 insertions(+), 91 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java diff --git a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java index d2b58153..78438cf0 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java +++ b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java @@ -10,6 +10,7 @@ import android.net.Uri; import android.util.Log; import android.widget.Toast; +import androidx.annotation.VisibleForTesting; import androidx.core.content.FileProvider; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Note; @@ -18,6 +19,7 @@ import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; @@ -150,19 +152,7 @@ public static void saveTxtToUri( try { String formattedContent = formatNoteContent(noteTitle, noteContent); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri); - - if (outputStream != null) { - // Add UTF-8 BOM for better compatibility - byte[] bom = new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; - outputStream.write(bom); - - byte[] contentBytes = formattedContent.getBytes(StandardCharsets.UTF_8); - - outputStream.write(contentBytes); - outputStream.flush(); - outputStream.close(); - + if (writeUtf8WithBom(context, uri, formattedContent)) { Toast.makeText( context, context.getString(R.string.fileSavedSuccessfully), @@ -182,75 +172,91 @@ public static void saveTxtToUri( } } + /** Measures how wide a piece of text is drawn. */ + interface TextWidth { + float of(String text); + } + + private static final int PDF_TOP = 50; + + /** Lowest baseline a body line may be drawn at before the next page starts. */ + private static final int PDF_BOTTOM = 800; + + // A4 in points. + private static final int PDF_PAGE_WIDTH = 595; + private static final int PDF_PAGE_HEIGHT = 842; + private static final int PDF_LEFT = 50; + private static final int PDF_LINE_HEIGHT = 20; + private static final int PDF_TEXT_WIDTH = 500; + + /** + * Writes text as UTF-8 behind a byte-order mark, closing the stream even when a write fails. + * + * @return false when the document could not be opened for writing. + */ + private static boolean writeUtf8WithBom(Context context, Uri uri, String text) + throws java.io.IOException { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(uri)) { + if (outputStream == null) { + return false; + } + // Add UTF-8 BOM for better compatibility + outputStream.write(new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); + outputStream.write(text.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + return true; + } + } + /** Save PDF content to selected URI */ public static void savePdfToUri( Context context, Uri uri, String noteTitle, String noteContent) { + PdfDocument pdfDocument = new PdfDocument(); try { - PdfDocument pdfDocument = new PdfDocument(); PdfDocument.PageInfo pageInfo = - new PdfDocument.PageInfo.Builder(595, 842, 1).create(); // A4 size + new PdfDocument.PageInfo.Builder(PDF_PAGE_WIDTH, PDF_PAGE_HEIGHT, 1).create(); PdfDocument.Page page = pdfDocument.startPage(pageInfo); Canvas canvas = page.getCanvas(); Paint paint = new Paint(); paint.setColor(Color.BLACK); - paint.setTextSize(12); paint.setAntiAlias(true); - int y = 50; - int lineHeight = 20; - int maxWidth = 500; + int y = PDF_TOP; String title = (noteTitle == null || noteTitle.trim().isEmpty()) ? "***" : noteTitle.trim(); paint.setTextSize(16); paint.setFakeBoldText(true); - canvas.drawText(title, 50, y, paint); - y += 30; // Extra space after title + for (String titleLine : wrapLine(title, PDF_TEXT_WIDTH, paint::measureText)) { + canvas.drawText(titleLine, PDF_LEFT, y, paint); + y += PDF_LINE_HEIGHT; + } + y += 10; // Extra space after title paint.setTextSize(12); paint.setFakeBoldText(false); if (noteContent != null && !noteContent.trim().isEmpty()) { - String[] lines = noteContent.trim().split("\n"); - - for (String line : lines) { - if (y > 800) { // Start new page if needed - pdfDocument.finishPage(page); - pageInfo = - new PdfDocument.PageInfo.Builder( - 595, 842, pdfDocument.getPages().size() + 1) - .create(); - page = pdfDocument.startPage(pageInfo); - canvas = page.getCanvas(); - y = 50; - } - - // Handle line wrapping - if (paint.measureText(line) > maxWidth) { - String[] words = line.split(" "); - StringBuilder currentLine = new StringBuilder(); - - for (String word : words) { - if (paint.measureText(currentLine + word + " ") > maxWidth) { - if (currentLine.length() > 0) { - canvas.drawText(currentLine.toString(), 50, y, paint); - y += lineHeight; - currentLine = new StringBuilder(word + " "); - } - } else { - currentLine.append(word).append(" "); - } - } - - if (currentLine.length() > 0) { - canvas.drawText(currentLine.toString(), 50, y, paint); - y += lineHeight; + for (String line : noteContent.trim().split("\n")) { + // The page break is checked per drawn line, not per paragraph: a long + // paragraph wraps to many lines and ran off the bottom of the page. + for (String drawn : wrapLine(line, PDF_TEXT_WIDTH, paint::measureText)) { + if (y > PDF_BOTTOM) { + pdfDocument.finishPage(page); + pageInfo = + new PdfDocument.PageInfo.Builder( + PDF_PAGE_WIDTH, + PDF_PAGE_HEIGHT, + pdfDocument.getPages().size() + 1) + .create(); + page = pdfDocument.startPage(pageInfo); + canvas = page.getCanvas(); + y = PDF_TOP; } - } else { - canvas.drawText(line, 50, y, paint); - y += lineHeight; + canvas.drawText(drawn, PDF_LEFT, y, paint); + y += PDF_LINE_HEIGHT; } } } else { @@ -259,35 +265,96 @@ public static void savePdfToUri( pdfDocument.finishPage(page); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri); - Log.d(TAG, "PDF OutputStream opened: " + (outputStream != null)); - - if (outputStream != null) { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(uri)) { + if (outputStream == null) { + Log.e(TAG, "PDF OutputStream is null"); + Toast.makeText( + context, + context.getString(R.string.errorSavingFile), + Toast.LENGTH_SHORT) + .show(); + return; + } pdfDocument.writeTo(outputStream); outputStream.flush(); - outputStream.close(); - - Log.d(TAG, "PDF written successfully"); - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); - } else { - Log.e(TAG, "PDF OutputStream is null"); - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); } - pdfDocument.close(); + Toast.makeText( + context, + context.getString(R.string.fileSavedSuccessfully), + Toast.LENGTH_SHORT) + .show(); } catch (Exception e) { + Log.e(TAG, "Error saving PDF file", e); Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) .show(); + } finally { + pdfDocument.close(); + } + } + + /** + * Splits one source line into the lines drawn for it, none wider than {@code maxWidth}. + * + *

Lines break between words; a single word wider than the whole line is broken between + * characters, since a word that cannot fit anywhere was otherwise never drawn at all. + */ + @VisibleForTesting + static List wrapLine(String line, float maxWidth, TextWidth width) { + List out = new ArrayList<>(); + if (width.of(line) <= maxWidth) { + out.add(line); + return out; + } + StringBuilder current = new StringBuilder(); + for (String word : line.split(" ", -1)) { + String candidate = current.length() == 0 ? word : current + " " + word; + if (width.of(candidate) <= maxWidth) { + current.setLength(0); + current.append(candidate); + continue; + } + if (current.length() > 0) { + out.add(current.toString()); + current.setLength(0); + } + while (width.of(word) > maxWidth) { + int cut = fittingPrefix(word, maxWidth, width); + out.add(word.substring(0, cut)); + word = word.substring(cut); + } + current.append(word); + } + if (current.length() > 0) { + out.add(current.toString()); + } + return out; + } + + /** + * The length of the longest prefix of {@code word} that fits, never splitting a surrogate pair + * and never less than one character, so the caller always makes progress. + */ + private static int fittingPrefix(String word, float maxWidth, TextWidth width) { + int low = word.offsetByCodePoints(0, 1); + int high = word.length(); + // Invariant: the prefix of length low is taken (it fits, or is the forced minimum). + while (low < high) { + int mid = (low + high + 1) >>> 1; + if (Character.isHighSurrogate(word.charAt(mid - 1))) { + mid--; // the prefix would end between the two halves of one character + } + if (mid <= low) { + break; + } + if (width.of(word.substring(0, mid)) <= maxWidth) { + low = mid; + } else { + high = mid - 1; + } } + return low; } /** Save HTML content to selected URI */ @@ -298,19 +365,7 @@ public static void saveHtmlToUri( HtmlTemplateGenerator.generateHtmlContent( context, noteTitle, noteContent, notes); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri); - - if (outputStream != null) { - // Add UTF-8 BOM for better compatibility - byte[] bom = new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; - outputStream.write(bom); - - byte[] contentBytes = htmlContent.getBytes(StandardCharsets.UTF_8); - - outputStream.write(contentBytes); - outputStream.flush(); - outputStream.close(); - + if (writeUtf8WithBom(context, uri, htmlContent)) { Toast.makeText( context, context.getString(R.string.fileSavedSuccessfully), diff --git a/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java b/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java new file mode 100644 index 00000000..1a1041de --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java @@ -0,0 +1,60 @@ +package com.pasich.mynotes.utils.file; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +/** Line wrapping for the PDF export, measured as one unit per character. */ +public class PdfLineWrapTest { + + private static final FileExportUtils.TextWidth CHARS = String::length; + + @Test + public void shortLine_isDrawnAsIs() { + assertEquals(List.of("hello"), FileExportUtils.wrapLine("hello", 10, CHARS)); + } + + @Test + public void longLine_breaksBetweenWords() { + assertEquals( + Arrays.asList("aaa bbb", "ccc ddd"), + FileExportUtils.wrapLine("aaa bbb ccc ddd", 7, CHARS)); + } + + @Test + public void wordWiderThanTheLine_isBrokenNotDropped() { + List lines = FileExportUtils.wrapLine("abcdefghijkl", 5, CHARS); + + assertEquals(Arrays.asList("abcde", "fghij", "kl"), lines); + } + + @Test + public void overlongWordAfterOthers_startsItsOwnLine() { + assertEquals( + Arrays.asList("ab", "cdefgh", "ij xy"), + FileExportUtils.wrapLine("ab cdefghij xy", 6, CHARS)); + } + + @Test + public void noDrawnLineIsWiderThanAllowed() { + String text = "lorem ipsum dolorsitametconsectetur adipiscing elit sed do eiusmod"; + for (String line : FileExportUtils.wrapLine(text, 9, CHARS)) { + assertTrue(line, line.length() <= 9); + } + } + + @Test + public void surrogatePairs_areNotSplit() { + String emoji = "😀"; // one character, two chars + List lines = FileExportUtils.wrapLine(emoji + emoji + emoji, 3, CHARS); + + for (String line : lines) { + assertTrue(!Character.isHighSurrogate(line.charAt(line.length() - 1))); + assertTrue(!Character.isLowSurrogate(line.charAt(0))); + } + assertEquals(emoji + emoji + emoji, String.join("", lines)); + } +} From ba1bf7f966e2efdde53566a3366289bbe535c1fe Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:28:25 +0200 Subject: [PATCH 17/52] perf(editor): close a word's undo step after a short pause, not on the 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. --- app/src/main/assets/editor/js/runtime.min.js | 2 +- notes_editor/src/custom/runtime.js | 30 +++++++-- notes_editor/test/history.test.mjs | 64 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/app/src/main/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js index 91fceb75..a33db179 100644 --- a/app/src/main/assets/editor/js/runtime.min.js +++ b/app/src/main/assets/editor/js/runtime.min.js @@ -2,4 +2,4 @@ * My Notes — Editor Runtime Build * (c) 2025 pasichDev. Editor.js — MIT */ -!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){t&&t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t))}function flushContent(){return t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let c=0;function reportViewStateNow(){clearTimeout(i),c=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-c>=250&&(c=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const s=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=a.undo.length>0||a.pending,e=a.redo.length>0&&!a.pending,n=`${t}|${e}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=a.epoch;readDocument().then(t=>{o===a.epoch&&(a.current=historyEntry(t,null),n&&(a.settleUntil=Date.now()+600),function(){const t=u;if(u=null,t?.current&&t.current.json===a.current?.json)try{a.undo=(t.undo||[]).map(importedEntry).slice(-s),a.redo=(t.redo||[]).map(importedEntry).slice(-s),a.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n){if(!a.current||a.applying||e)return;if(n&&Date.now()=3e3;a.timer=setTimeout(captureHistory,t||o?0:700),reportHistory()}function historyInputStarting(){!a.current||a.applying||e||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(a.timer),a.timer=null,!t||!a.current||!a.pending&&!e)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(t=>{if(n!==a.epoch||!a.current)return;const e=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(historyDoc(a.current),t),a.undo.push({...a.current,caret:e||a.current.caret}),a.undo.length>s&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,e.caret),a.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,c,s),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(t){return a.queue=a.queue.then(t).catch(t=>console.error("[History]",t)),a.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.undo.pop();if(t&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.redo.pop();if(t&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!0,!1))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange("insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(a.batchHasText||/\S/.test(e)),!1),/\S/.test(e)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});function escapeHtml(t){return t.replace(/&/g,"&").replace(//g,">")}function plainTextBlocks(t){const e=t.split("\n").map(escapeHtml);for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const f=new Map;let p=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=f.get(t);n&&(f.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);a.current&&(a.current=strip(a.current));const e=distinct(a.undo.map(strip));a.current&&e.length&&e[e.length-1].json===a.current.json&&e.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=e,a.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(t=>t.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const t=a.undo.map(exportedEntry),e=a.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=a.current.json.length+size(t)+size(e);for(;n>d&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); +!function(){"use strict";let t=null,e=!1,n=null;function safeAndroidCall(t,...e){if(window.Android&&"function"==typeof window.Android[t])try{return window.Android[t](...e)}catch(e){console.error(`[AndroidBridge] ${t} failed:`,e)}return null}window.attachEditorInstance=function(n){t=n,window.__EDITOR_READY=!0,e=!!n.readOnly?.isEnabled,applyTitleEditable()};const o="1"===new URLSearchParams(window.location.search).get("dbltap");function saveContent(){t&&t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentChanged",e))}).catch(t=>console.error("[Editor] Save failed:",t))}function flushContent(){return t?t.save().then(t=>{const e=JSON.stringify(t.blocks);e!==n&&(n=e,safeAndroidCall("onContentFlushed",e))}).catch(t=>console.error("[Editor] Flush failed:",t)):Promise.resolve()}let r=null;function reportViewportAnchor(){if(!t)return;if(window.scrollY<1)return void safeAndroidCall("onViewportAnchor",-1,0);const e=t.blocks.getBlocksCount();for(let n=0;n0)return void safeAndroidCall("onViewportAnchor",n,Math.round(o.top))}}window.addEventListener("scroll",()=>{clearTimeout(r),r=setTimeout(reportViewportAnchor,200)},{passive:!0});let i=null,l=!1;function blockForHolder(e){const n=t.blocks.getBlocksCount();for(let o=0;o0?Math.min(1,window.scrollY/e):0,topId:null,topIndex:-1,topOffset:0,caret:caretPosition()};if(window.scrollY>=1){const e=t.blocks.getBlocksCount();for(let o=0;o0){n.topId=e.id,n.topIndex=o,n.topOffset=Math.round(r.top);break}}}return n}function reportViewState(){t&&l&&safeAndroidCall("onViewState",JSON.stringify(currentViewState()))}let c=0;function reportViewStateNow(){clearTimeout(i),c=Date.now(),reportViewState()}function scheduleViewStateReport(){clearTimeout(i),Date.now()-c>=250&&(c=Date.now(),reportViewState()),i=setTimeout(reportViewStateNow,200)}function placeCaret(t,e){const n=document.createTreeWalker(t,NodeFilter.SHOW_TEXT);let o=Math.max(0,e||0),r=n.nextNode(),i=null;for(;r&&(i=r,!(o<=r.length));)o-=r.length,r=n.nextNode();const l=document.createRange();r?l.setStart(r,o):i?l.setStart(i,i.length):l.setStart(t,0),l.collapse(!0);const c=window.getSelection();c.removeAllRanges(),c.addRange(l)}function restoreViewState(n){if(!t||!n)return;if(n.caretId&&!e){const e=t.blocks.getById(n.caretId);if(e?.holder){try{t.caret.setToBlock(e,"start")}catch(t){console.error("[Editor] setToBlock failed:",t)}const o=editableInputs(e.holder),r=o[Math.min(n.caretInput||0,o.length-1)];r&&(r.focus({preventScroll:!0}),placeCaret(r,n.caretOffset))}}let o=n.topId?t.blocks.getById(n.topId):null;if(!o&&n.topIndex>=0){const e=t.blocks.getBlocksCount();e>0&&(o=t.blocks.getBlockByIndex(Math.min(n.topIndex,e-1)))}o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(n.topOffset||0))})}function keepCaretVisible(){const t=window.getSelection();if(!t||0===t.rangeCount)return;const e=t.focusNode,n=e&&(1===e.nodeType?e:e.parentElement);if(!n||!n.isContentEditable)return;const o=t.getRangeAt(0).cloneRange();o.collapse(!1);let r=o.getClientRects()[0];(!r||0===r.top&&0===r.bottom)&&(r=n.getBoundingClientRect());const i=window.innerHeight;r.bottom>i-24?window.scrollBy(0,r.bottom-i+24):r.top<24&&window.scrollBy(0,r.top-24)}window.addEventListener("scroll",scheduleViewStateReport,{passive:!0}),document.addEventListener("selectionchange",scheduleViewStateReport),document.addEventListener("visibilitychange",()=>{document.hidden&&reportViewStateNow()}),window.addEventListener("resize",()=>requestAnimationFrame(keepCaretVisible));const s=100,a={undo:[],redo:[],current:null,pending:!1,batchHasText:!1,caretBefore:null,lastCaret:null,batchStart:0,timer:null,closing:!1,applying:!1,settleUntil:0,epoch:0,changeSeq:0,queue:Promise.resolve(),reported:""};function historyTitleElement(){return document.getElementById("noteTitleInput")}function historyCaret(){if(!t||e)return null;const n=window.getSelection();if(!n||0===n.rangeCount||!n.focusNode)return null;const o=historyTitleElement();if(o&&o.contains(n.focusNode)){const t=document.createRange();return t.selectNodeContents(o),t.setEnd(n.focusNode,n.focusOffset),{title:!0,offset:t.toString().length}}return caretPosition()}function readDocument(){return t.save().then(t=>({title:historyTitleElement()?.innerText||"",blocks:t.blocks}))}function historyEntry(t,e){return{json:JSON.stringify(t),caret:e}}function historyDoc(t){return JSON.parse(t.json)}function reportHistory(){const t=a.undo.length>0||a.pending,e=a.redo.length>0&&!a.pending,n=`${t}|${e}`;n!==a.reported&&(a.reported=n,safeAndroidCall("onHistoryChanged",t,e))}function attachmentUrls(t){const e=new Set;for(const n of t?.blocks||[]){const t=n?.data;if(t?.file?.url&&e.add(t.file.url),Array.isArray(t?.files))for(const n of t.files)n?.url&&e.add(n.url)}return e}function keepAttachmentsForUndo(t,e){const n=attachmentUrls(e),o=[...attachmentUrls(t)].filter(t=>!n.has(t));o.length>0&&safeAndroidCall("keepForUndo",JSON.stringify(o))}function stopHistory(){a.epoch++,clearTimeout(a.timer),a.timer=null,a.closing=!1,a.undo=[],a.redo=[],a.current=null,a.pending=!1,a.caretBefore=null,a.lastCaret=null,a.applying=!1,a.settleUntil=0,reportHistory()}const d=15e5;let u=null;function exportedEntry(t){return{json:t.json,caret:t.caret||null}}function importedEntry(t){return historyEntry(JSON.parse(t.json),t.caret||null)}function resetHistory(n){if(stopHistory(),!t||e)return;const o=a.epoch;readDocument().then(t=>{o===a.epoch&&(a.current=historyEntry(t,null),n&&(a.settleUntil=Date.now()+600),function(){const t=u;if(u=null,t?.current&&t.current.json===a.current?.json)try{a.undo=(t.undo||[]).map(importedEntry).slice(-s),a.redo=(t.redo||[]).map(importedEntry).slice(-s),a.current.caret=t.current.caret||null}catch(t){console.error("[History] import failed:",t),a.undo=[],a.redo=[]}}(),reportHistory())}).catch(t=>console.error("[History] reset failed:",t))}function historyNoteChange(t,n,o){if(!a.current||a.applying||e)return;if(n&&Date.now()=3e3;if(!t&&!r&&n&&a.closing&&a.timer)return void reportHistory();a.closing=!!o&&!t,clearTimeout(a.timer);const i=t||r?0:o?300:700;a.timer=setTimeout(captureHistory,i),reportHistory()}function historyInputStarting(){!a.current||a.applying||e||a.pending||a.caretBefore||(a.caretBefore=historyCaret())}function captureHistory(e){if(clearTimeout(a.timer),a.timer=null,a.closing=!1,!t||!a.current||!a.pending&&!e)return Promise.resolve();const n=a.epoch,o=a.changeSeq;return readDocument().then(t=>{if(n!==a.epoch||!a.current)return;const e=a.caretBefore,r=o!==a.changeSeq;a.pending=r,a.caretBefore=null,r?a.batchStart=Date.now():a.batchHasText=!1;const i=historyEntry(t,historyCaret());i.json!==a.current.json&&(keepAttachmentsForUndo(historyDoc(a.current),t),a.undo.push({...a.current,caret:e||a.current.caret}),a.undo.length>s&&a.undo.shift(),a.redo=[],a.current=i),reportHistory()})}function findBlock(e){if(!e)return null;const n=t.blocks.getBlocksCount();for(let o=0;oreadDocument()).then(n=>{r===a.epoch&&(a.current=historyEntry(n,e.caret),a.settleUntil=Date.now()+600,function(e,n,o){const r=historyTitleElement();if(e?.title&&r)r.focus({preventScroll:!0}),placeCaret(r,e.offset);else if(e?.id&&findBlock(e.id))restoreViewState({caretId:e.id,caretInput:e.input,caretOffset:e.offset,topId:findBlock(n?.topId)?n.topId:null,topIndex:n?n.topIndex:-1,topOffset:n?.topOffset||0});else{const e=findBlock(o);if(e)try{t.caret.setToBlock(e,"end")}catch(t){console.error("[History] placing the caret failed:",t)}n&&restoreViewState({topId:findBlock(n.topId)?n.topId:null,topIndex:n.topIndex,topOffset:n.topOffset})}requestAnimationFrame(()=>requestAnimationFrame(keepCaretVisible))}(e.caret,c,s),saveContent())}).catch(t=>console.error("[History] applying a step failed:",t)).finally(()=>{r===a.epoch&&(a.applying=!1),reportHistory()})}function runHistoryAction(t){return a.queue=a.queue.then(t).catch(t=>console.error("[History]",t)),a.queue}function historyUndo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.undo.pop();if(t&&a.current)return a.redo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}function historyRedo(){t&&!e&&runHistoryAction(()=>captureHistory(!0).then(()=>{const t=a.redo.pop();if(t&&a.current)return a.undo.push({...a.current,caret:historyCaret()||a.current.caret}),applyHistoryEntry(t)}))}document.addEventListener("keydown",t=>{const e=function(t){if(!t.ctrlKey&&!t.metaKey||t.altKey)return null;const e=(t.key||"").toLowerCase();return"z"===e?t.shiftKey?"redo":"undo":"y"!==e||t.shiftKey?null:"redo"}(t);if(e)return t.preventDefault(),t.stopPropagation(),void("undo"===e?historyUndo():historyRedo());(function(t){if(t.ctrlKey||t.metaKey||t.altKey)return!1;if(229===t.keyCode)return!0;const e=t.key||"";return 1===e.length||"Enter"===e||"Backspace"===e||"Delete"===e||"Tab"===e})(t)&&(historyInputStarting(),"Enter"===t.key&&historyNoteChange(!1,!1,!0))},!0),document.addEventListener("beforeinput",t=>{if("historyUndo"===t.inputType||"historyRedo"===t.inputType)return t.preventDefault(),void("historyUndo"===t.inputType?historyUndo():historyRedo());historyInputStarting();const e="string"==typeof t.data?t.data:"";historyNoteChange(!1,!1,"insertParagraph"===t.inputType||"insertLineBreak"===t.inputType||/\s$/.test(e)&&(a.batchHasText||/\S/.test(e))),/\S/.test(e)&&(a.batchHasText=!0)},!0),document.addEventListener("selectionchange",()=>{a.pending||a.applying||(a.lastCaret=historyCaret())});function escapeHtml(t){return t.replace(/&/g,"&").replace(//g,">")}function plainTextBlocks(t){const e=t.split("\n").map(escapeHtml);for(;e.length&&""===e[0].trim();)e.shift();for(;e.length&&""===e[e.length-1].trim();)e.pop();const paragraph=t=>({type:"paragraph",data:{text:t}});if(e.filter(t=>""!==t.trim()).length<=200){const t=[];let n="";for(const o of e)""===o.trim()?n+=o+"
":(t.push(paragraph(n+o)),n="");return t}const n=[];let o=[],r=0;for(let t=0;t=1e3&&l&&(n.push(paragraph(o.join("
"))),o=[],r=0)}return o.length&&n.push(paragraph(o.join("
"))),n}const f=new Map;let p=0;function requestUpload(t,e){return new Promise(n=>{const o=`u${Date.now()}_${++p}`;f.set(o,n);!0!==safeAndroidCall("requestPickedUpload",o,t,e.name||"",e.size||0)&&function(t){return new Promise((e,n)=>{const o=new FileReader;o.onload=()=>e(o.result),o.onerror=n,o.readAsDataURL(t)})}(e).then(n=>{!0!==safeAndroidCall("uploadBase64Async",o,t,n,e.name||"file")&&finishUpload(o,null)}).catch(()=>finishUpload(o,null))})}function finishUpload(t,e){const n=f.get(t);n&&(f.delete(t),n(e))}function updateTitlePlaceholder(){const t=document.getElementById("noteTitleInput");t.innerText.trim()?t.removeAttribute("data-placeholder"):t.setAttribute("data-placeholder",window.__titlePlaceholder||"")}window.__onUploadFinished=finishUpload,window.deleteAttachmentBlockFromAndroid=function(e,n){if(t)try{const o=t.blocks.getById(e);if(!o)return;const r=o.holder,i=[...r.parentNode.children].indexOf(r);if(i<0)return;t.blocks.delete(i),function(t){const strip=e=>{const n=historyDoc(e),o=n.blocks.filter(e=>e.id!==t);return o.length===n.blocks.length?e:historyEntry({title:n.title,blocks:o},e.caret)},distinct=t=>t.filter((e,n)=>0===n||e.json!==t[n-1].json);a.current&&(a.current=strip(a.current));const e=distinct(a.undo.map(strip));a.current&&e.length&&e[e.length-1].json===a.current.json&&e.pop();const n=distinct(a.redo.map(strip).reverse());a.current&&n.length&&n[0].json===a.current.json&&n.shift(),a.undo=e,a.redo=n.reverse(),reportHistory()}(e),setTimeout(()=>{safeAndroidCall("onAttachmentBlockDeletedResponse",e,n)},30)}catch(t){console.error("[Delete] JS ERROR:",t)}};const h=document.getElementById("noteTitleInput");function applyTitleEditable(){const t=document.getElementById("noteTitleInput");t&&(t.setAttribute("contenteditable",e?"false":"true"),e&&document.activeElement===t&&t.blur())}function setReadMode(n,o){if(!t?.readOnly)return Promise.resolve(e);const r=currentViewState();return e=n,applyTitleEditable(),t.readOnly.toggle(n).then(()=>(n||a.current||resetHistory(!0),restoreViewState({caretId:!n&&o?o.id:null,caretInput:o?o.input:0,caretOffset:o?o.offset:0,topId:r.topId,topIndex:r.topIndex,topOffset:r.topOffset}),safeAndroidCall("onReadModeChanged",n,!!o),n))}h.addEventListener("input",()=>{updateTitlePlaceholder(),safeAndroidCall("onTitleChanged",h.innerText.trim()),historyNoteChange(!1,!1)}),document.addEventListener("dblclick",n=>{if(!o||!e||!t)return;const r=function(t,e){const n=document.caretRangeFromPoint?.(t,e);if(!n)return null;const o=n.startContainer,r=1===o.nodeType?o:o.parentElement,i=r?.closest(".ce-block");if(!i)return null;const l=blockForHolder(i);if(!l)return null;const c=[...i.querySelectorAll("[contenteditable]")],s=c.find(t=>t.contains(o));if(!s)return{id:l.id,input:0,offset:0};const a=document.createRange();return a.selectNodeContents(s),a.setEnd(o,n.startOffset),{id:l.id,input:c.indexOf(s),offset:a.toString().length}}(n.clientX,n.clientY);window.getSelection()?.removeAllRanges(),r&&(n.preventDefault(),setReadMode(!1,r))}),window.setThemeColors=function(t){if(!t)return;const e=document.documentElement;for(const n in t)e.style.setProperty(`--${n}`,t[n])},window.loadNote=function(o){if(!t)return;document.getElementById("noteTitleInput").innerText=o.title||"",updateTitlePlaceholder();let r=[];o.plainTextFallback&&o.plainText?r=plainTextBlocks(o.plainText):o.valueJson&&(r=o.valueJson),l=!1,stopHistory(),u=o.history||null,t.render({blocks:r}).then(()=>{if(n=JSON.stringify(r),resetHistory(),o.viewState?restoreViewState(o.viewState):function(e){if(!t||!e||e.index<0)return;const n=t.blocks.getBlocksCount();if(0===n)return;const o=t.blocks.getBlockByIndex(Math.min(e.index,n-1));o?.holder&&requestAnimationFrame(()=>{const t=o.holder.getBoundingClientRect().top;window.scrollTo(0,window.scrollY+t-(e.offset||0))})}(o.anchor),o.focusStart&&!e&&!o.viewState?.caretId){const e=o.viewState;let n=e?.topId?t.blocks.getById(e.topId):null;if(!n&&e?.topIndex>=0){const o=t.blocks.getBlocksCount();o>0&&(n=t.blocks.getBlockByIndex(Math.min(e.topIndex,o-1)))}try{n?t.caret.setToBlock(n,"start"):t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}}o.viewState||o.anchor?.index>=0||window.scrollTo(0,0),safeAndroidCall("onNoteRendered"),requestAnimationFrame(()=>requestAnimationFrame(()=>{l=!0,reportViewState()}))})},window.uploadAttachment=async function(t){const e=await requestUpload("file",t);return e&&e.success&&e.file?{success:1,file:{url:e.file.url,name:t.name,size:t.size,extension:t.name.split(".").pop()}}:{success:0,file:null}},window.toggleReadModeFromAndroid=function(){setReadMode(!e,null)},window.setReadMode=setReadMode,window.saveContent=saveContent,window.plainTextBlocks=plainTextBlocks,window.currentBlockIndex=function(){return t?t.blocks.getCurrentBlockIndex():-1},window.insertUploadedBlockFromAndroid=function(e,n,o){if(!t||!n||!n.url)return;const r=t.blocks.getBlocksCount(),i=o>=0&&o<=r?o:r;"image"===e?t.blocks.insert("image",{file:n},void 0,i,!1):t.blocks.insert("attaches",{file:n,title:n.name||""},void 0,i,!1)},window.flushContent=flushContent,window.finishPage=function(t){return(t?runHistoryAction(()=>(e?Promise.resolve():captureHistory(!0)).then(()=>{if(!a.current)return;const t=a.undo.map(exportedEntry),e=a.redo.map(exportedEntry),size=t=>t.reduce((t,e)=>t+e.json.length,0);let n=a.current.json.length+size(t)+size(e);for(;n>d&&(t.length||e.length);)n-=(t.length>=e.length?t.shift():e.shift()).json.length;n>d||safeAndroidCall("onHistoryExported",JSON.stringify({current:exportedEntry(a.current),undo:t,redo:e}))})):Promise.resolve()).catch(t=>console.error("[History] export failed:",t)).then(()=>flushContent()).finally(()=>safeAndroidCall("onPageFinished"))},window.currentViewStateJson=function(){return t&&l?JSON.stringify(currentViewState()):null},window.focusCaretFromAndroid=function(){if(!t||e)return;const n=document.activeElement;if(n&&n.isContentEditable)return void n.focus({preventScroll:!0});const o=window.scrollY;try{t.caret.setToFirstBlock("start")}catch(t){console.error("[Editor] placing the caret failed:",t)}window.scrollTo(0,o)},window.historyUndo=historyUndo,window.historyRedo=historyRedo,window.historyEditorChanged=function(t){historyNoteChange((Array.isArray(t)?t:[t]).some(t=>t&&t.type&&"block-changed"!==t.type),!0)},window.uploadImage=async function(t){const e=await requestUpload("image",t);return e&&e.success&&e.file?{success:1,file:e.file}:{success:0}}}(); diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index 91275653..342ca747 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -346,6 +346,8 @@ window.addEventListener('resize', () => requestAnimationFrame(keepCaretVisible)) const HISTORY_LIMIT = 100 const HISTORY_IDLE_MS = 700 const HISTORY_MAX_BATCH_MS = 3000 +// A finished word or line closes its batch after this pause. +const HISTORY_WORD_END_MS = 300 // Editor.js reports a change up to 400 ms after it happened; a report that late about a state // this history applied itself is not a new change. const HISTORY_SETTLE_MS = 600 @@ -361,6 +363,8 @@ const __hist = { lastCaret: null, batchStart: 0, timer: null, + // The timer closes the batch at the end of a word or line. + closing: false, applying: false, settleUntil: 0, epoch: 0, @@ -444,6 +448,7 @@ function stopHistory () { __hist.epoch++ clearTimeout(__hist.timer) __hist.timer = null + __hist.closing = false __hist.undo = [] __hist.redo = [] __hist.current = null @@ -537,9 +542,15 @@ function resetHistory (settle) { /** * Something in the note changed, or is about to. {@code immediate} closes the batch now. - * {@code fromEditor} marks Editor.js's own late change report. + * {@code fromEditor} marks Editor.js's own late change report. {@code endsWord} closes the batch + * once typing pauses for a moment (HISTORY_WORD_END_MS) instead of at once. + * + * Closing a batch saves and serializes the whole note, so a finished word or line no longer does + * it on the spot: on a long note that cost was paid on every space. A brief pause still makes the + * word or line its own step; words typed without that pause become one step. Editor.js's late + * report of the same typing does not push a pending close back. */ -function historyNoteChange (immediate, fromEditor) { +function historyNoteChange (immediate, fromEditor, endsWord) { if (!__hist.current || __hist.applying || isReadMode) return if (fromEditor && Date.now() < __hist.settleUntil) return __hist.changeSeq++ @@ -549,9 +560,15 @@ function historyNoteChange (immediate, fromEditor) { __hist.batchStart = Date.now() if (!__hist.caretBefore) __hist.caretBefore = __hist.lastCaret } - clearTimeout(__hist.timer) const overdue = Date.now() - __hist.batchStart >= HISTORY_MAX_BATCH_MS - __hist.timer = setTimeout(captureHistory, immediate || overdue ? 0 : HISTORY_IDLE_MS) + if (!immediate && !overdue && fromEditor && __hist.closing && __hist.timer) { + reportHistory() + return + } + __hist.closing = !!endsWord && !immediate + clearTimeout(__hist.timer) + const delay = immediate || overdue ? 0 : endsWord ? HISTORY_WORD_END_MS : HISTORY_IDLE_MS + __hist.timer = setTimeout(captureHistory, delay) reportHistory() } @@ -568,6 +585,7 @@ function historyInputStarting () { function captureHistory (force) { clearTimeout(__hist.timer) __hist.timer = null + __hist.closing = false if (!editor || !__hist.current || (!__hist.pending && !force)) return Promise.resolve() const epoch = __hist.epoch const seq = __hist.changeSeq @@ -792,7 +810,7 @@ document.addEventListener( } if (isEditingKey(event)) { historyInputStarting() - if (event.key === 'Enter') historyNoteChange(true, false) + if (event.key === 'Enter') historyNoteChange(false, false, true) } }, true @@ -814,7 +832,7 @@ document.addEventListener( const data = typeof event.data === 'string' ? event.data : '' const lineBreak = event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak' const endsWord = lineBreak || (/\s$/.test(data) && (__hist.batchHasText || /\S/.test(data))) - historyNoteChange(endsWord, false) + historyNoteChange(false, false, endsWord) if (/\S/.test(data)) __hist.batchHasText = true }, true diff --git a/notes_editor/test/history.test.mjs b/notes_editor/test/history.test.mjs index 1890a601..3ca8627b 100644 --- a/notes_editor/test/history.test.mjs +++ b/notes_editor/test/history.test.mjs @@ -87,3 +87,67 @@ test('the last edit is sent before the page reports it is finished', async () => assert.ok(names.lastIndexOf('onContentFlushed') < names.indexOf('onPageFinished')) assert.match(page.callsTo('onContentFlushed').at(-1)[0], /three/) }) + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +/** A loaded page whose editor counts how often the whole document is saved. */ +async function countingPage () { + const page = openPage() + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'one' } }]) + let saves = 0 + const save = editor.save + editor.save = () => { saves++; return save() } + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + return { page, state, saves: () => saves } +} + +/** Types {@code text} into the block, a character at a time, as beforeinput reports it. */ +function type (page, state, text) { + for (const ch of text) { + page.fire('document', 'beforeinput', { inputType: 'insertText', data: ch }) + state.blocks[0].data = { text: state.blocks[0].data.text + ch } + } +} + +test('a finished word becomes its own step after a short pause, not on the spot', async () => { + const { page, state, saves } = await countingPage() + const before = saves() + + type(page, state, ' two ') + await settle() + assert.equal(saves() - before, 0, 'the space does not save the whole note at once') + + await wait(350) + assert.equal(saves() - before, 1) + assert.equal(page.window.__internals.history.undo.length, 1) + + page.window.historyUndo() + await settle() + assert.equal(state.blocks[0].data.text, 'one') +}) + +test("Editor.js's late report of the typing does not push the word's step back", async () => { + const { page, state } = await countingPage() + + type(page, state, ' two ') + await wait(150) + page.window.historyEditorChanged({ type: 'block-changed' }) + await wait(250) + + assert.equal(page.window.__internals.history.undo.length, 1, 'closed 300 ms after the space') +}) + +test('a line break closes its step the same way', async () => { + const { page, state, saves } = await countingPage() + type(page, state, ' two') + const before = saves() + + page.fire('document', 'beforeinput', { inputType: 'insertParagraph' }) + await settle() + assert.equal(saves() - before, 0) + + await wait(350) + assert.equal(page.window.__internals.history.undo.length, 1) +}) From 491cc61036908908394c742a0b632b652c8ba026 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:30:03 +0200 Subject: [PATCH 18/52] fix(tasks): stop reminders of deleted or completed tasks 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. --- .../mynotes/ui/presenter/TasksPresenter.java | 2 + .../ui/receiver/TaskReminderReceiver.java | 107 +++++++++---- .../utils/reminder/ReminderRescheduler.java | 18 ++- .../ui/receiver/TaskReminderReceiverTest.java | 142 ++++++++++++++++++ .../reminder/ReminderReschedulerTest.java | 34 +++++ 5 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java index 6f46c3e7..98f1063f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java @@ -126,6 +126,8 @@ public void toggleTask(Task task) { @Override public void deleteTask(Task task) { + // A deleted task must not keep announcing itself (every N minutes for a repeating one). + TaskReminderManager.cancelReminder(appContext, task.getId()); getCompositeDisposable() .add( getDataManager() diff --git a/app/src/main/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiver.java b/app/src/main/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiver.java index e4481e6e..6a5dd0e3 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiver.java +++ b/app/src/main/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiver.java @@ -4,75 +4,130 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.TaskStackBuilder; import com.pasich.mynotes.R; import com.pasich.mynotes.cache.NotificationPreferencesCache; import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.model.Task; import com.pasich.mynotes.ui.view.activity.ReminderTapActivity; import com.pasich.mynotes.ui.view.activity.TasksActivity; import com.pasich.mynotes.utils.reminder.TaskReminderManager; import dagger.hilt.android.AndroidEntryPoint; +import io.reactivex.schedulers.Schedulers; import javax.inject.Inject; +/** + * Posts task reminder notifications and re-arms a reminder that repeats every N minutes. + * + *

Each alarm is checked against the task as stored now, not as it was when the alarm was armed: + * a task deleted, completed or given another time meanwhile (here or by sync) is not announced. The + * database is written before the broadcast finishes, so the process being killed right after cannot + * leave a reminder that already fired stored as pending. + */ @AndroidEntryPoint public class TaskReminderReceiver extends BroadcastReceiver { + private static final String TAG = "TaskReminderReceiver"; + private static final int NOTIF_ID_OFFSET = 100000; + /** An alarm arriving this much before the stored time is stale and is re-armed instead. */ + private static final long EARLY_TOLERANCE_MS = 60_000L; + public static final String ACTION_DISMISS_TASK = "com.pasich.mynotes.ACTION_DISMISS_TASK_REMINDER"; @Inject DataManager dataManager; + @Inject AppDatabase database; + @Inject NotificationPreferencesCache notificationPreferencesCache; @Override public void onReceive(Context ctx, Intent intent) { int taskId = intent.getIntExtra(TaskReminderManager.EXTRA_TASK_ID, -1); if (taskId == -1) return; + Context app = ctx.getApplicationContext(); + boolean dismiss = ACTION_DISMISS_TASK.equals(intent.getAction()); + + PendingResult pending = goAsync(); + Schedulers.io() + .scheduleDirect(() -> { + try { + if (dismiss) cancelIntervalReminder(app, taskId); + else handle(app, taskId, System.currentTimeMillis()); + } catch (RuntimeException e) { + Log.e(TAG, "task reminder handling failed", e); + } finally { + pending.finish(); + } + }); + } - if (ACTION_DISMISS_TASK.equals(intent.getAction())) { - cancelIntervalReminder(ctx, taskId); + @WorkerThread + @VisibleForTesting + void handle(Context ctx, int taskId, long now) { + Task task = loadTask(taskId); + if (task == null || task.isDone() || task.getReminderTime() == null) { + // Deleted, completed or turned off since the alarm was armed. + TaskReminderManager.cancelReminder(ctx, taskId); + return; + } + long due = task.getReminderTime(); + if (due > now + EARLY_TOLERANCE_MS) { + // Armed for a time the task no longer has, typically moved by sync; wait for it. + TaskReminderManager.scheduleReminder(ctx, task); return; } - String title = intent.getStringExtra(TaskReminderManager.EXTRA_TASK_TITLE); - int intervalMinutes = - intent.getIntExtra(TaskReminderManager.EXTRA_TASK_INTERVAL_MINUTES, 0); - - showNotification(ctx, taskId, title, intervalMinutes); + int intervalMinutes = task.getReminderIntervalMinutes(); + showNotification(ctx, taskId, task.getTitle(), intervalMinutes); if (intervalMinutes > 0) { - long nextTime = System.currentTimeMillis() + intervalMinutes * 60_000L; - dataManager - .setTaskReminderFull(taskId, nextTime, intervalMinutes) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .subscribe(() -> {}, e -> {}); - Task tempTask = new Task(); - tempTask.setId(taskId); - tempTask.setTitle(title != null ? title : ""); - tempTask.setReminderTime(nextTime); - tempTask.setReminderIntervalMinutes(intervalMinutes); - TaskReminderManager.scheduleReminder(ctx, tempTask); + long nextTime = now + intervalMinutes * 60_000L; + try { + dataManager.setTaskReminderFull(taskId, nextTime, intervalMinutes).blockingAwait(); + } catch (RuntimeException e) { + Log.e(TAG, "advancing the task reminder failed", e); + } + task.setReminderTime(nextTime); + TaskReminderManager.scheduleReminder(ctx, task); return; } - dataManager - .clearTaskReminder(taskId) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .subscribe(() -> {}, e -> {}); + try { + dataManager.clearTaskReminder(taskId).blockingAwait(); + } catch (RuntimeException e) { + Log.e(TAG, "clearTaskReminder failed", e); + } } + @Nullable + private Task loadTask(int taskId) { + try { + return database.taskDao().getTaskSync(taskId); + } catch (RuntimeException e) { + Log.w(TAG, "task lookup failed", e); + return null; + } + } + + @WorkerThread private void cancelIntervalReminder(Context ctx, int taskId) { TaskReminderManager.cancelReminder(ctx, taskId); - dataManager - .clearTaskReminder(taskId) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .subscribe(() -> {}, e -> {}); NotificationManagerCompat.from(ctx).cancel(taskId + NOTIF_ID_OFFSET); + try { + dataManager.clearTaskReminder(taskId).blockingAwait(); + } catch (RuntimeException e) { + Log.e(TAG, "clearTaskReminder failed", e); + } } private void showNotification(Context ctx, int taskId, String title, int intervalMinutes) { diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java index 12998f94..ea6194f5 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java @@ -8,6 +8,7 @@ import com.pasich.mynotes.data.model.RepeatRule; import com.pasich.mynotes.data.model.Task; import dagger.hilt.android.qualifiers.ApplicationContext; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import javax.inject.Inject; @@ -75,11 +76,26 @@ public synchronized Result rescheduleAll(long now) { if (!ReminderManager.armSnooze( context, snooze.noteId, snooze.time, snooze.intervalMinutes)) inexact++; } - List tasks = database.taskDao().getTasksWithRemindersSync(); + List tasks = new ArrayList<>(); + for (Task task : database.taskDao().getTasksWithRemindersSync()) { + if (shouldArm(task, now)) tasks.add(task); + } TaskReminderManager.rescheduleAll(context, tasks); return new Result(armed, inexact); } + /** + * Same rule as for notes: a reminder repeating every N minutes keeps going, a one-time one + * missed by more than {@link #MISSED_ONE_TIME_GRACE_MS} is left alone. + */ + static boolean shouldArm(@NonNull Task task, long now) { + Long time = task.getReminderTime(); + if (time == null || task.isDone()) return false; + if (time > now) return true; + if (task.getReminderIntervalMinutes() > 0) return true; + return now - time <= MISSED_ONE_TIME_GRACE_MS; + } + /** * Arms or cancels the given notes' reminders to match the database, after sync wrote them: a * reminder set, moved or turned off on another device, or a note deleted there. diff --git a/app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java b/app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java new file mode 100644 index 00000000..2d138563 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java @@ -0,0 +1,142 @@ +package com.pasich.mynotes.ui.receiver; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.Manifest; +import android.app.AlarmManager; +import android.app.Application; +import android.app.NotificationManager; +import android.content.Context; +import androidx.room.Room; +import com.pasich.mynotes.cache.NotificationPreferencesCache; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.model.Task; +import io.reactivex.Completable; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlarmManager; + +/** A task reminder is checked against the stored task before it is announced. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class TaskReminderReceiverTest { + + private static final long NOW = 1_780_000_000_000L; + + private Application context; + private AppDatabase db; + private DataManager dataManager; + private TaskReminderReceiver receiver; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS); + ShadowAlarmManager.setCanScheduleExactAlarms(true); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + dataManager = mock(DataManager.class); + when(dataManager.clearTaskReminder(anyInt())).thenReturn(Completable.complete()); + when(dataManager.setTaskReminderFull(anyInt(), anyLong(), anyInt())) + .thenReturn(Completable.complete()); + NotificationPreferencesCache channels = mock(NotificationPreferencesCache.class); + when(channels.getChannelId()).thenReturn("reminders"); + + receiver = new TaskReminderReceiver(); + receiver.dataManager = dataManager; + receiver.database = db; + receiver.notificationPreferencesCache = channels; + } + + @After + public void tearDown() { + db.close(); + ShadowAlarmManager.reset(); + } + + private int task(long time, int intervalMinutes, boolean done) { + Task task = new Task("Buy milk", 0); + task.setReminderTime(time); + task.setReminderIntervalMinutes(intervalMinutes); + task.setDone(done); + return (int) db.taskDao().insertTask(task); + } + + private int posted() { + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + return shadowOf(nm).getAllNotifications().size(); + } + + private int armed() { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + return shadowOf(am).getScheduledAlarms().size(); + } + + @Test + public void deletedTask_isNotAnnouncedNorRearmed() { + receiver.handle(context, 404, NOW); + + assertThat(posted()).isEqualTo(0); + assertThat(armed()).isEqualTo(0); + verify(dataManager, never()).setTaskReminderFull(anyInt(), anyLong(), anyInt()); + } + + @Test + public void completedTask_isNotAnnounced() { + int id = task(NOW, 15, true); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(0); + assertThat(armed()).isEqualTo(0); + } + + @Test + public void oneTimeReminder_isAnnouncedAndCleared() { + int id = task(NOW, 0, false); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(1); + verify(dataManager).clearTaskReminder(id); + assertThat(armed()).isEqualTo(0); + } + + @Test + public void repeatingReminder_isAnnouncedAndArmedAgain() { + int id = task(NOW, 15, false); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(1); + verify(dataManager).setTaskReminderFull(eq(id), eq(NOW + 15 * 60_000L), eq(15)); + assertThat(armed()).isEqualTo(1); + } + + @Test + public void reminderMovedLater_waitsForItsNewTime() { + int id = task(NOW + 3_600_000L, 0, false); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(0); + assertThat(armed()).isEqualTo(1); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java index 31a8d9f8..cd0923b1 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java @@ -10,6 +10,7 @@ import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.RepeatRule; +import com.pasich.mynotes.data.model.Task; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -75,6 +76,39 @@ private List alarmsFor(int noteId, String act return found; } + private int task(long time, int intervalMinutes) { + Task task = new Task("Task", 0); + task.setReminderTime(time); + task.setReminderIntervalMinutes(intervalMinutes); + return (int) db.taskDao().insertTask(task); + } + + private List taskAlarmsFor(int taskId) { + List found = new ArrayList<>(); + for (ShadowAlarmManager.ScheduledAlarm alarm : alarms.getScheduledAlarms()) { + Intent intent = shadowOf(alarm.operation).getSavedIntent(); + if (intent.getIntExtra(TaskReminderManager.EXTRA_TASK_ID, -1) == taskId) { + found.add(alarm); + } + } + return found; + } + + @Test + public void rescheduleAll_taskReminders_sameGraceAsNotes() { + int upcoming = task(NOW + HOUR, 0); + int recent = task(NOW - HOUR, 0); + int stale = task(NOW - 3 * 24 * HOUR, 0); + int repeating = task(NOW - 3 * 24 * HOUR, 15); + + rescheduler.rescheduleAll(NOW); + + assertThat(taskAlarmsFor(upcoming)).hasSize(1); + assertThat(taskAlarmsFor(recent)).hasSize(1); + assertThat(taskAlarmsFor(stale)).isEmpty(); + assertThat(taskAlarmsFor(repeating)).hasSize(1); + } + @Test public void rescheduleAll_armsUpcomingReminders() { int id = note(NOW + 2 * HOUR, "NONE", false); From d32d04bb8438119a6061af8914d269e6318ca5d3 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:30:53 +0200 Subject: [PATCH 19/52] fix(editor): quote the block id and file URL in the delete-block script deleteAttachmentBlockRequest pasted the block id into evaluated JavaScript as-is and escaped the URL only for single quotes. Pass both as JSON string literals. --- .../utils/EditorJSInterface.java | 10 ++++- .../utils/EditorJSInterfaceScriptTest.java | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java index 3252b6c7..14d0f935 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java @@ -619,9 +619,15 @@ public void toggleReadMode() { */ public void deleteAttachmentBlockRequest(String blockId, String fileUrl) { // If fileUrl == null → JS should receive “null” instead of “null string” - String jsUrl = (fileUrl == null) ? "null" : "'" + fileUrl.replace("'", "\\'") + "'"; + // Both come from the note's document: quoted as JS string literals, never pasted in raw. + String jsUrl = (fileUrl == null) ? "null" : JSONObject.quote(fileUrl); - evaluate("deleteAttachmentBlockFromAndroid('" + blockId + "', " + jsUrl + ");"); + evaluate( + "deleteAttachmentBlockFromAndroid(" + + JSONObject.quote(blockId == null ? "" : blockId) + + ", " + + jsUrl + + ");"); } /** diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java new file mode 100644 index 00000000..e1eaa94e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java @@ -0,0 +1,41 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.webkit.WebView; +import com.pasich.mynotes.extendedEditor.models.SettingsEditorJsBridge; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Values from a note's document reach the page's scripts as string literals, never as code. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class EditorJSInterfaceScriptTest { + + @Test + public void aBlockIdAndUrlCannotBreakOutOfTheirStrings() { + WebView webView = mock(WebView.class); + EditorJSInterface bridge = + new EditorJSInterface( + mock(EditorJSInterface.EditorListener.class), + webView, + RuntimeEnvironment.getApplication(), + new SettingsEditorJsBridge(false)); + + bridge.deleteAttachmentBlockRequest("a');Android.x();('", "editorjs://a\\'b"); + + ArgumentCaptor script = ArgumentCaptor.forClass(String.class); + verify(webView).evaluateJavascript(script.capture(), any()); + assertThat(script.getValue()) + .isEqualTo( + "deleteAttachmentBlockFromAndroid(\"a');Android.x();('\"," + + " \"editorjs:\\/\\/a\\\\'b\");"); + } +} From e9052c7b8082f74ad3f8f2854c7b2baab937b57f Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:30:57 +0200 Subject: [PATCH 20/52] fix(share): open only other apps' content as a note, and never crash 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. --- .../ui/view/activity/ShareActivity.java | 9 ++- .../utils/shareProcessors/ShareProcessor.java | 68 ++++++++++++++---- .../shareProcessors/ShareProcessorTest.java | 71 +++++++++++++++++++ 3 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/ShareActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/ShareActivity.java index c670a1b2..e73a0d9f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/ShareActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/ShareActivity.java @@ -11,7 +11,6 @@ import com.pasich.mynotes.utils.shareProcessors.ShareProcessor; import com.pasich.mynotes.utils.shareProcessors.SharedNoteCreator; import dagger.hilt.android.AndroidEntryPoint; -import java.io.IOException; import javax.inject.Inject; /** @@ -43,6 +42,12 @@ protected void onCreate(Bundle savedInstanceState) { if (Intent.ACTION_VIEW.equals(intent.getAction())) { Uri uri = intent.getData(); + if (!ShareProcessor.isReadableSharedUri(uri, getPackageName())) { + Toast.makeText(this, getString(R.string.notSupportedShare), Toast.LENGTH_LONG) + .show(); + finish(); + return; + } ShareProcessor.readFileAsync( getContentResolver(), @@ -54,7 +59,7 @@ public void onSuccess(String content) { } @Override - public void onError(IOException e) { + public void onError(Exception e) { runOnUiThread(() -> { Toast.makeText( ShareActivity.this, diff --git a/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java b/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java index b6c9b1b4..9ebe559c 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java +++ b/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java @@ -3,9 +3,13 @@ import android.content.ContentResolver; import android.content.Intent; import android.net.Uri; +import androidx.annotation.Nullable; import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Locale; public class ShareProcessor { @@ -25,30 +29,64 @@ public static String extractSharedText(Intent intent) { return text != null ? text : ""; } + /** + * Whether a file another app asks to open as a note may be read. The handler is exported, so + * any app can send it any URI: only another app's content is read. A {@code file://} URI or one + * of this app's own providers would copy the app's private files into a note the sender could + * then get back. + * + * @param packageName this app's package name; its providers' authorities start with it. + */ + public static boolean isReadableSharedUri(@Nullable Uri uri, String packageName) { + if (uri == null || !ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) { + return false; + } + String authority = uri.getAuthority(); + if (authority == null || authority.isEmpty()) return false; + String own = packageName.toLowerCase(Locale.ROOT); + // An authority may list several, separated by semicolons. + for (String one : authority.toLowerCase(Locale.ROOT).split(";")) { + String name = one.contains("@") ? one.substring(one.lastIndexOf('@') + 1) : one; + if (name.equals(own) || name.startsWith(own + ".")) return false; + } + return true; + } + + /** + * Reads the text at {@code uri} on a background thread. Any failure, a provider that refuses or + * returns nothing included, reaches {@code callback.onError} instead of crashing the thread. + */ public static void readFileAsync(ContentResolver resolver, Uri uri, FileReadCallback callback) { new Thread( () -> { + String text; try { - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = - new BufferedReader( - new InputStreamReader( - resolver.openInputStream(uri)))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - - if (sb.length() > MAX_SHARE_SIZE) break; - } - } - callback.onSuccess(sb.toString()); - } catch (IOException e) { + text = readText(resolver, uri); + } catch (Exception e) { callback.onError(e); + return; } + callback.onSuccess(text); }) .start(); } + private static String readText(ContentResolver resolver, Uri uri) throws IOException { + InputStream in = resolver.openInputStream(uri); + if (in == null) throw new IOException("No content for " + uri); + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + + if (sb.length() > MAX_SHARE_SIZE) break; + } + } + return sb.toString(); + } + public static boolean isTooLarge(String text) { if (text == null) return false; return text.getBytes().length > MAX_SHARE_SIZE; @@ -57,6 +95,6 @@ public static boolean isTooLarge(String text) { public interface FileReadCallback { void onSuccess(String content); - void onError(IOException error); + void onError(Exception error); } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java b/app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java new file mode 100644 index 00000000..26bf7a76 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java @@ -0,0 +1,71 @@ +package com.pasich.mynotes.utils.shareProcessors; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.ContentResolver; +import android.net.Uri; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** The exported "open with" handler reads only other apps' content into a note. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ShareProcessorTest { + + private static final String PACKAGE = "com.pasich.mynotes"; + + private static boolean readable(String uri) { + return ShareProcessor.isReadableSharedUri(uri == null ? null : Uri.parse(uri), PACKAGE); + } + + @Test + public void anotherAppsContentIsRead() { + assertThat(readable("content://com.android.providers.downloads.documents/document/12")) + .isTrue(); + assertThat(readable("content://com.pasich.mynotesfake/file.txt")).isTrue(); + } + + @Test + public void nothingFilesOrTheAppsOwnProvidersAreRefused() { + assertThat(readable(null)).isFalse(); + assertThat(readable("file:///data/data/com.pasich.mynotes/databases/notes.db")).isFalse(); + assertThat(readable("FILE:///sdcard/a.txt")).isFalse(); + assertThat(readable("content://com.pasich.mynotes.provider/files/a.txt")).isFalse(); + assertThat(readable("content://COM.PASICH.MYNOTES.provider/a")).isFalse(); + assertThat(readable("content://0@com.pasich.mynotes.provider/a")).isFalse(); + assertThat(readable("content://com.pasich.mynotes/a")).isFalse(); + assertThat(readable("https://example.com/a.txt")).isFalse(); + } + + @Test + public void aProviderWithNothingToReadReportsAnErrorInsteadOfCrashing() throws Exception { + ContentResolver resolver = RuntimeEnvironment.getApplication().getContentResolver(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + ShareProcessor.readFileAsync( + resolver, + Uri.parse("content://com.example.missing/a.txt"), + new ShareProcessor.FileReadCallback() { + @Override + public void onSuccess(String content) { + done.countDown(); + } + + @Override + public void onError(Exception e) { + error.set(e); + done.countDown(); + } + }); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(error.get()).isNotNull(); + } +} From 9ee09404da7bc91a325726726c03aeb44673394e Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:31:04 +0200 Subject: [PATCH 21/52] fix(backup): let the backup destination sheet survive a rotation 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. --- .../ui/view/activity/BackupActivity.java | 188 +++++++++--------- .../ui/view/dialogs/BackupOptionsDialog.java | 50 ++++- .../utils/backup/BackupCacheHelper.java | 2 + .../view/dialogs/BackupOptionsDialogTest.java | 35 ++++ 4 files changed, 175 insertions(+), 100 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java index 69f87da6..6abcccbc 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java @@ -82,7 +82,7 @@ /** Activity for creating and restoring app data backups. */ @AndroidEntryPoint public class BackupActivity extends BaseActivity - implements BackupContract.view, AccountSyncFragment.Host { + implements BackupContract.view, AccountSyncFragment.Host, BackupOptionsDialog.Host { private static final String TAG = "BackupActivity"; @@ -1001,115 +1001,121 @@ public void initActivity() { /** Save local backup (2/3) - start intent save json file */ @Override public void openIntentSaveBackup(JsonBackup jsonBackup) { - BackupOptionsDialog.newInstance( - new BackupOptionsCallback() { - @Override - public void onGoogleDrive() { - saveBackupToGoogleDrive( - BackupActivity.this, - ScramblerBackupHelper.encodeString(jsonBackup), - new DriveProcess() { - @Override - public void onSuccess(Uri uri, String nameFile) { - // Формуємо інтент для Drive - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("*/*"); - intent.putExtra( - Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN); - intent.putExtra( - Intent.EXTRA_MIME_TYPES, - new String[] { - "application/zip", - "application/octet-stream" - }); - intent.putExtra(Intent.EXTRA_STREAM, uri); - intent.putExtra( - Intent.EXTRA_SUBJECT, - FILE_NAME_BACKUP_MNBKN); - intent.setPackage(GOOGLE_DRIVE_PACKAGE); - intent.addFlags( - Intent.FLAG_GRANT_READ_URI_PERMISSION); - launchGoogleDriveIntent( - launcherExportDrive, - intent, - R.string.export_drive_error); - } - - @Override - public void onError(String error) { - onInfoSnack( - error, - null, - SnackBarInfo.Error, - Snackbar.LENGTH_LONG); - } - }); - } - - @Override - public void onDeviceStorage() { - intentExportDevice.launch( - new Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .putExtra( - Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN) - .setType("*/*") - .putExtra( - Intent.EXTRA_MIME_TYPES, - new String[] { - "application/zip", - "application/octet-stream" - })); - } - }, - false) + BackupOptionsDialog.newInstance(false) .show(getSupportFragmentManager(), "ShareOptionsDialog"); } - /** Restore local backup (2/3) - start intent load json file */ + @NonNull @Override - public void openIntentReadBackup() { - BackupOptionsDialog.newInstance( - new BackupOptionsCallback() { + public BackupOptionsCallback backupOptionsCallback(boolean isRestore) { + return isRestore ? restoreOptionsCallback() : saveOptionsCallback(); + } + + /** + * Where a backup is saved to. + * + *

Asked of the activity when the user chooses, not captured when the dialog opens: after a + * rotation the dialog is recreated and the activity that opened it is gone. + */ + private BackupOptionsCallback saveOptionsCallback() { + return new BackupOptionsCallback() { + @Override + public void onGoogleDrive() { + JsonBackup jsonBackup = ((BackupPresenter) presenter).serviceCache.getJsonBackup(); + if (jsonBackup == null) { + // The process was recreated since the backup was gathered. + createLocalCopyFinish(false); + return; + } + saveBackupToGoogleDrive( + BackupActivity.this, + ScramblerBackupHelper.encodeString(jsonBackup), + new DriveProcess() { @Override - public void onGoogleDrive() { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN); + public void onSuccess(Uri uri, String nameFile) { + // Формуємо інтент для Drive + Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("*/*"); + intent.putExtra(Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN); intent.putExtra( Intent.EXTRA_MIME_TYPES, new String[] { - "application/json", - "application/zip", - "application/octet-stream" + "application/zip", "application/octet-stream" }); - intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.putExtra(Intent.EXTRA_SUBJECT, FILE_NAME_BACKUP_MNBKN); intent.setPackage(GOOGLE_DRIVE_PACKAGE); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); launchGoogleDriveIntent( - launcherImportDrive, intent, R.string.drive_selected_error); + launcherExportDrive, intent, R.string.export_drive_error); } @Override - public void onDeviceStorage() { - intentImportDevice.launch( - new Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra( - Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN) - .putExtra( - Intent.EXTRA_MIME_TYPES, - new String[] { - "application/json", - "application/zip", - "application/octet-stream" - })); + public void onError(String error) { + onInfoSnack(error, null, SnackBarInfo.Error, Snackbar.LENGTH_LONG); } - }, - true) + }); + } + + @Override + public void onDeviceStorage() { + intentExportDevice.launch( + new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .putExtra(Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN) + .setType("*/*") + .putExtra( + Intent.EXTRA_MIME_TYPES, + new String[] { + "application/zip", "application/octet-stream" + })); + } + }; + } + + /** Restore local backup (2/3) - start intent load json file */ + @Override + public void openIntentReadBackup() { + BackupOptionsDialog.newInstance(true) .show(getSupportFragmentManager(), "ShareOptionsDialog"); } + /** Where a backup is restored from; see {@link #saveOptionsCallback()}. */ + private BackupOptionsCallback restoreOptionsCallback() { + return new BackupOptionsCallback() { + @Override + public void onGoogleDrive() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.putExtra(Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN); + intent.setType("*/*"); + intent.putExtra( + Intent.EXTRA_MIME_TYPES, + new String[] { + "application/json", "application/zip", "application/octet-stream" + }); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setPackage(GOOGLE_DRIVE_PACKAGE); + launchGoogleDriveIntent(launcherImportDrive, intent, R.string.drive_selected_error); + } + + @Override + public void onDeviceStorage() { + intentImportDevice.launch( + new Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + .putExtra(Intent.EXTRA_TITLE, FILE_NAME_BACKUP_MNBKN) + .putExtra( + Intent.EXTRA_MIME_TYPES, + new String[] { + "application/json", + "application/zip", + "application/octet-stream" + })); + } + }; + } + @Override public void showErrorsText(int errorCode, int string) { onInfoSnack(string, null, SnackBarInfo.Error, Snackbar.LENGTH_LONG); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialog.java index 73c1409f..d684c27c 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialog.java @@ -16,20 +16,44 @@ import com.pasich.mynotes.databinding.DialogBackupOptionsBinding; import com.pasich.mynotes.utils.file.FileExportUtils; +/** + * Asks where a backup is saved to or restored from. + * + *

The fragment is recreated by the framework after a rotation or process death, through the + * no-argument constructor, so it keeps nothing but its arguments: the choice is handed to the + * hosting activity, which implements {@link Host}. + */ public class BackupOptionsDialog extends BaseDialogBottomSheets { + private static final String ARG_IS_RESTORE = "isRestore"; + + /** The screen that acts on the choice; whichever instance of it is current. */ + public interface Host { + /** The callback for a restore ({@code isRestore}) or for saving a backup. */ + @NonNull + BackupOptionsCallback backupOptionsCallback(boolean isRestore); + } + private DialogBackupOptionsBinding binding; - private final BackupOptionsCallback callback; - private final boolean isRestore; - public BackupOptionsDialog(BackupOptionsCallback callback, boolean isRestore) { - this.callback = callback; - this.isRestore = isRestore; + public BackupOptionsDialog() {} + + public static BackupOptionsDialog newInstance(boolean isRestore) { + BackupOptionsDialog dialog = new BackupOptionsDialog(); + Bundle args = new Bundle(); + args.putBoolean(ARG_IS_RESTORE, isRestore); + dialog.setArguments(args); + return dialog; + } + + private boolean isRestore() { + Bundle args = getArguments(); + return args != null && args.getBoolean(ARG_IS_RESTORE, false); } - public static BackupOptionsDialog newInstance( - BackupOptionsCallback callback, boolean isRestore) { - return new BackupOptionsDialog(callback, isRestore); + @Nullable + private BackupOptionsCallback callback() { + return getActivity() instanceof Host host ? host.backupOptionsCallback(isRestore()) : null; } @Nullable @@ -44,7 +68,7 @@ public View onCreateView( initListeners(); - if (isRestore) { + if (isRestore()) { binding.backupOptions.setText(R.string.restore_backup); } else { binding.backupOptions.setText(R.string.save_backup); @@ -59,13 +83,21 @@ public View onCreateView( @Override public void initListeners() { binding.googleDrive.setOnClickListener(v -> { + BackupOptionsCallback callback = callback(); if (callback != null) callback.onGoogleDrive(); dismiss(); }); binding.deviceStorage.setOnClickListener(v -> { + BackupOptionsCallback callback = callback(); if (callback != null) callback.onDeviceStorage(); dismiss(); }); } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java b/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java index dfcfcb5a..8a2f5710 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java @@ -2,7 +2,9 @@ import com.pasich.mynotes.utils.backup.models.JsonBackup; import javax.inject.Inject; +import javax.inject.Singleton; +@Singleton public class BackupCacheHelper { private JsonBackup jsonBackup; diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java new file mode 100644 index 00000000..a6095229 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java @@ -0,0 +1,35 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class BackupOptionsDialogTest { + + @Test + public void canBeRecreatedByTheFramework() { + // What the fragment manager does after a rotation or process death. + Fragment recreated = + new FragmentFactory() + .instantiate( + getClass().getClassLoader(), BackupOptionsDialog.class.getName()); + + assertTrue(recreated instanceof BackupOptionsDialog); + } + + @Test + public void keepsTheModeInItsArguments() { + BackupOptionsDialog dialog = BackupOptionsDialog.newInstance(true); + + assertNotNull(dialog.getArguments()); + assertTrue(dialog.getArguments().getBoolean("isRestore")); + } +} From 50f1d6ee8bf6af0fd6939077d4f99ebd43146369 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:31:12 +0200 Subject: [PATCH 22/52] fix(sync): keep edits saved during a sync from being reverted by its 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. --- .../mynotes/data/sync/RoomSyncStore.java | 62 ++++++- .../data/sync/RoomSyncStoreStaleEditTest.java | 158 ++++++++++++++++++ 2 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index 1751d0ee..f8b721f7 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -103,6 +103,16 @@ public final class RoomSyncStore implements SyncStore { */ private final Map builtVersionIds = new ConcurrentHashMap<>(); + /** + * The {@code updatedAt} every record had, here, when the last build read its content. + * + *

The apply's stale-edit guard compares against this. It used to compare against the merged + * record's timestamp, which is another device's clock whenever the remote version wins: with + * that clock ahead, a local edit made during the sync looked older than the result and was + * overwritten, and with it behind, an untouched record refused every newer remote version. + */ + private final Map builtUpdatedAts = new ConcurrentHashMap<>(); + /** * Attachments the last build described from the column alone because their file is gone, keyed * by hash, so the service can name the note when no endpoint holds the blob either. @@ -234,8 +244,19 @@ public SnapshotBuildResult buildSnapshot() throws IOException { List problems = new ArrayList<>(); rememberedOnlyAttachments.clear(); builtVersionIds.clear(); + builtUpdatedAts.clear(); try { - for (SyncMetadataEntity metadata : database.syncMetadataDao().getAll()) { + for (SyncMetadataEntity listed : database.syncMetadataDao().getAll()) { + // Re-read right before the content, never after it. The timestamp then predates + // whatever content is read next: an edit saved in between leaves newer content + // under an older timestamp, which the apply's guard sees and skips. Read the other + // way round, an edit landing between the two paired the old content with the new + // timestamp, the guard let the apply through, and the edit was reverted everywhere. + SyncMetadataEntity metadata = + database.syncMetadataDao().get(listed.recordType, listed.localId); + if (metadata == null) { + continue; + } // Every record carries the version the remote is known to hold, so the merge can // tell an edit made here alone from one made on both sides. if (metadata.deletedAt != null) { @@ -250,22 +271,29 @@ public SnapshotBuildResult buildSnapshot() throws IOException { } JsonObject payload = payload(metadata, problems); if (payload != null) { - SyncMetadataEntity current = - database.syncMetadataDao().get(metadata.recordType, metadata.localId); + long updatedAt = metadata.updatedAt; + if (SyncMetadata.RECORD_TYPE_PREFERENCES.equals(metadata.recordType)) { + // The one record whose payload() writes its own timestamp: a settings + // change noticed just now is touched there, after the values were read. + SyncMetadataEntity touched = + database.syncMetadataDao() + .get(metadata.recordType, metadata.localId); + if (touched != null) { + updatedAt = touched.updatedAt; + } + } records.add( SyncRecord.live( SyncRecord.Type.fromWireValue(metadata.recordType), metadata.stableId, - Instant.ofEpochMilli( - current == null - ? metadata.updatedAt - : current.updatedAt), + Instant.ofEpochMilli(updatedAt), payload) .withBaseVersion(metadata.syncedVersionId)); } } for (SyncRecord record : records) { builtVersionIds.put(recordKey(record), record.getCanonicalPayloadHash()); + builtUpdatedAts.put(recordKey(record), record.getUpdatedAt().toEpochMilli()); } } finally { hashCache.flush(); @@ -377,7 +405,7 @@ private void applySnapshotInternal( // which no longer exists, so applying its result would silently // drop the newer edit. Leave it; the next sync merges the real // current version. - if (metadata.updatedAt > record.getUpdatedAt().toEpochMilli()) { + if (editedSinceBuild(key, metadata, record.getUpdatedAt().toEpochMilli())) { Log.w( TAG, "Skipping a stale sync result for " @@ -425,7 +453,10 @@ private void applySnapshotInternal( String baseline = preferences.getString(PREFERENCES_HASH, null); boolean edited = baseline != null && !baseline.equals(live); boolean stale = - preferencesMetadata[0].updatedAt > stagedPreferencesUpdatedAt; + editedSinceBuild( + preferencesKey, + preferencesMetadata[0], + stagedPreferencesUpdatedAt); if (stagedPreferencesJson == null || edited || stale) { // Unusable payload, or edited since the build: the version // is not going to be committed, so it is not recorded as @@ -868,6 +899,19 @@ else if (SyncMetadata.RECORD_TYPE_PREFERENCES.equals(metadata.recordType)) { return result; } + /** + * Whether the local record moved on after the last build read it; see the apply's guard. + * + *

A record the build did not produce (an apply without a build, or a record whose content + * could not be read) has no timestamp of its own to compare with; the merged record's is the + * best remaining evidence. + */ + private boolean editedSinceBuild( + @NonNull String key, @NonNull SyncMetadataEntity current, long mergedUpdatedAt) { + Long built = builtUpdatedAts.get(key); + return current.updatedAt > (built != null ? built : mergedUpdatedAt); + } + /** * Records the version this device now holds, writing only the columns that differ. * diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java new file mode 100644 index 00000000..879af5ab --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java @@ -0,0 +1,158 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import androidx.room.Room; +import com.google.gson.JsonObject; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Collections; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * An edit saved while a sync is in flight survives the apply that sync ends with. + * + *

The apply skips a record that moved on locally after the build read it. Two things defeated + * that guard: the build paired content read first with a timestamp read afterwards, and the guard + * compared against the merged record's timestamp, which is another device's clock. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreStaleEditTest { + + private static final String NOTE_ID = "11111111-1111-4111-8111-111111111111"; + private static final String ATTACHMENT_ID = "66666666-6666-4666-8666-666666666666"; + + private AppDatabase db; + private Context context; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void anEditSavedWhileTheBuildReadsTheNoteIsNotRevertedByTheApply() throws Exception { + File blob = File.createTempFile("attachment", ".bin"); + blob.deleteOnExit(); + Files.write(blob.toPath(), "blob".getBytes(StandardCharsets.UTF_8)); + int noteId = + seedNote( + "Shopping", + "[{\"id\":\"" + + ATTACHMENT_ID + + "\",\"url\":\"file:///notes/a.bin\",\"name\":\"a\"," + + "\"extension\":\"bin\",\"size\":4}]"); + boolean[] edited = {false}; + RoomSyncStore store = + new RoomSyncStore( + context, + db, + mock(PreferenceHelper.class), + (ignored, attachment) -> blob, + file -> { + // The editor saves after the build read the note's row but before it + // finished describing the note. + if (!edited[0]) { + edited[0] = true; + saveEdit(noteId, "Edited meanwhile", 2_000L); + } + return Sha256.of(file); + }); + + SyncSnapshot built = store.readSnapshot(); + // Nothing on the remote side: the merge result is the build itself. + store.applySnapshot(built, Collections.emptyList()); + + assertThat(edited[0]).isTrue(); + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Edited meanwhile"); + } + + @Test + public void aLocalEditDuringSyncSurvivesARemoteVersionFromAClockAhead() throws Exception { + int noteId = seedNote("Shopping", null); + RoomSyncStore store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + SyncSnapshot built = store.readSnapshot(); + + saveEdit(noteId, "Local edit", 2_000L); + // The remote device's clock runs well ahead of this one. + store.applySnapshot( + new SyncSnapshot( + Collections.singletonList( + renamed(built, "Remote", Instant.ofEpochMilli(50_000L)))), + Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Local edit"); + } + + @Test + public void anUntouchedNoteTakesANewerRemoteVersionFromAClockBehind() throws Exception { + int noteId = seedNote("Shopping", null); + RoomSyncStore store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + SyncSnapshot built = store.readSnapshot(); + + // The merge chose the remote version; its device's clock runs behind this one. + store.applySnapshot( + new SyncSnapshot( + Collections.singletonList( + renamed(built, "Remote", Instant.ofEpochMilli(500L)))), + Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Remote"); + } + + private void saveEdit(int noteId, String title, long at) { + Note stored = db.noteDao().getNoteSync(noteId); + db.noteDao() + .updateNoteContent( + noteId, + title, + stored.getValue(), + stored.getValueJson(), + stored.getDate(), + stored.getTag(), + stored.getAttachments()); + db.syncMetadataDao().touch(SyncMetadata.RECORD_TYPE_NOTE, noteId, at); + } + + private static SyncRecord renamed(SyncSnapshot snapshot, String title, Instant updatedAt) { + SyncRecord note = snapshot.find(SyncRecord.Type.NOTE, NOTE_ID); + JsonObject payload = note.getPayload().deepCopy(); + payload.addProperty("b", title); + return SyncRecord.live(SyncRecord.Type.NOTE, NOTE_ID, updatedAt, payload); + } + + private int seedNote(String title, String attachmentsJson) { + Note note = new Note().create(title, "Milk", 1_000L, ""); + note.setAttachments(attachmentsJson); + int id = db.noteDao().addNote(note).intValue(); + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, id, NOTE_ID, 1_000L, null)); + return id; + } +} From 4c8d6c708821f0f473285b00978f413d205923c2 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:33:07 +0200 Subject: [PATCH 23/52] fix(share): export off the main thread and survive recreation 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. --- .../ui/view/activity/MainActivity.java | 2 +- .../ui/view/dialogs/MoreNoteDialog.java | 6 +- .../ui/view/dialogs/ShareOptionsDialog.java | 445 +++++++++++------- .../mynotes/utils/file/FileExportUtils.java | 79 ++-- .../dialogs/ShareOptionsDialogArgsTest.java | 74 +++ 5 files changed, 391 insertions(+), 215 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 6c0503a4..790205ba 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -1114,7 +1114,7 @@ public void shareNotes() { List selected = selectionController.getSelectedNotes(); if (!selected.isEmpty()) { - ShareOptionsDialog dialog = new ShareOptionsDialog(selected); + ShareOptionsDialog dialog = ShareOptionsDialog.newInstance(selected); dialog.show(getSupportFragmentManager(), "ShareOptionsDialog"); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java index 5c2dd968..9a1e04b6 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java @@ -245,7 +245,8 @@ public void onStopTrackingTouch(@NonNull Slider slider) { }); binding.quickShare.setOnClickListener(v -> { - ShareOptionsDialog shareDialog = new ShareOptionsDialog(mPresenter.getNote()); + ShareOptionsDialog shareDialog = + ShareOptionsDialog.newInstance(mPresenter.getNote()); shareDialog.show(getParentFragmentManager(), "ShareOptionsDialog"); dismiss(); }); @@ -291,7 +292,8 @@ public void onStopTrackingTouch(@NonNull Slider slider) { }); binding.share.setOnClickListener(v -> { - ShareOptionsDialog shareDialog = new ShareOptionsDialog(mPresenter.getNote()); + ShareOptionsDialog shareDialog = + ShareOptionsDialog.newInstance(mPresenter.getNote()); shareDialog.show(getParentFragmentManager(), "ShareOptionsDialog"); dismiss(); }); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java index 965a2800..51eb2fd1 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java @@ -1,153 +1,315 @@ package com.pasich.mynotes.ui.view.dialogs; +import android.annotation.SuppressLint; import android.app.Activity; +import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; +import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.pasich.mynotes.R; import com.pasich.mynotes.base.dialog.BaseDialogBottomSheets; +import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.databinding.DialogShareOptionsBinding; import com.pasich.mynotes.utils.file.FileExportUtils; +import dagger.hilt.EntryPoint; +import dagger.hilt.InstallIn; +import dagger.hilt.android.EntryPointAccessors; +import dagger.hilt.components.SingletonComponent; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; - -/** Dialog for advanced sharing options */ +import java.util.Map; + +/** + * Dialog for advanced sharing options. + * + *

Only the notes' ids live in the arguments, so the dialog can be rebuilt after a rotation or + * after the process was killed while the file picker was open; the notes are then read again from + * the database. Files are built and written on a background thread. + */ public class ShareOptionsDialog extends BaseDialogBottomSheets { - private Note mNote; - private List mSelectedNotes; - private boolean isDataExport; + private static final String TAG = "ShareOptionsDialog"; + + private static final String ARG_NOTE_IDS = "noteIds"; + private static final String ARG_SINGLE = "single"; + private static final String ARG_DATA_EXPORT = "dataExport"; + private static final String ARG_SINGLE_TITLE = "singleTitle"; + + /** SQLite caps the number of bound parameters; ids are looked up in chunks below it. */ + private static final int ID_CHUNK = 500; + private DialogShareOptionsBinding binding; // Activity result launchers for file saving private ActivityResultLauncher saveTxtLauncher; private ActivityResultLauncher savePdfLauncher; private ActivityResultLauncher saveHtmlLauncher; - // Current data for saving - private String currentNoteTitle; - private String currentNoteContent; + + /** The notes as handed in; null after the dialog was recreated, until read again. */ + @Nullable private List notes; + + @Nullable private Disposable pendingLoad; + + @EntryPoint + @InstallIn(SingletonComponent.class) + public interface ShareEntryPoint { + AppDatabase appDatabase(); + } public ShareOptionsDialog() {} - public ShareOptionsDialog(Note note) { - this(); - this.mNote = note; - this.mSelectedNotes = null; - this.isDataExport = false; + /** + * @deprecated use {@link #newInstance(List, boolean)}; kept for callers not yet moved over. + */ + @Deprecated + @SuppressLint("ValidFragment") + public ShareOptionsDialog(List selectedNotes, boolean isDataExport) { + setArguments(arguments(selectedNotes, false, isDataExport)); + this.notes = selectedNotes == null ? null : new ArrayList<>(selectedNotes); } - public ShareOptionsDialog(List selectedNotes) { - this(); - this.mNote = null; - this.mSelectedNotes = selectedNotes; - this.isDataExport = false; + public static ShareOptionsDialog newInstance(@NonNull Note note) { + return create(Collections.singletonList(note), true, false); } - public ShareOptionsDialog(List selectedNotes, boolean isDataExport) { - this(); - this.mNote = null; - this.mSelectedNotes = selectedNotes; - this.isDataExport = isDataExport; + public static ShareOptionsDialog newInstance(@NonNull List selectedNotes) { + return create(selectedNotes, false, false); } - public static ShareOptionsDialog newInstance(Note note) { - ShareOptionsDialog dialog = new ShareOptionsDialog(); - dialog.mNote = note; - dialog.mSelectedNotes = null; - dialog.isDataExport = false; - return dialog; + public static ShareOptionsDialog newInstance( + @NonNull List selectedNotes, boolean isDataExport) { + return create(selectedNotes, false, isDataExport); } - public static ShareOptionsDialog newInstance(List selectedNotes) { + private static ShareOptionsDialog create( + List selectedNotes, boolean single, boolean isDataExport) { ShareOptionsDialog dialog = new ShareOptionsDialog(); - dialog.mNote = null; - dialog.mSelectedNotes = selectedNotes; - dialog.isDataExport = false; + dialog.setArguments(arguments(selectedNotes, single, isDataExport)); + dialog.notes = new ArrayList<>(selectedNotes); return dialog; } - public static ShareOptionsDialog newInstance(List selectedNotes, boolean isDataExport) { - ShareOptionsDialog dialog = new ShareOptionsDialog(); - dialog.mNote = null; - dialog.mSelectedNotes = selectedNotes; - dialog.isDataExport = isDataExport; - return dialog; + @VisibleForTesting + static Bundle arguments(List selectedNotes, boolean single, boolean isDataExport) { + List list = selectedNotes == null ? Collections.emptyList() : selectedNotes; + int[] ids = new int[list.size()]; + for (int i = 0; i < ids.length; i++) ids[i] = list.get(i).getId(); + Bundle args = new Bundle(); + args.putIntArray(ARG_NOTE_IDS, ids); + args.putBoolean(ARG_SINGLE, single); + args.putBoolean(ARG_DATA_EXPORT, isDataExport); + if (single && !list.isEmpty()) args.putString(ARG_SINGLE_TITLE, list.get(0).getTitle()); + return args; + } + + /** What is exported: a file title and its text. */ + @VisibleForTesting + static final class Export { + final String title; + final String content; + + Export(String title, String content) { + this.title = title; + this.content = content; + } + } + + private static String titleOrStars(@Nullable String title) { + return (title == null || title.isEmpty()) ? "***" : title; + } + + /** The file title, known from the arguments alone. */ + @VisibleForTesting + static String exportTitle(@NonNull Bundle args) { + if (args.getBoolean(ARG_SINGLE)) return titleOrStars(args.getString(ARG_SINGLE_TITLE)); + return args.getBoolean(ARG_DATA_EXPORT) ? "Data_Export" : "Multiple_Notes"; + } + + /** Builds the export of {@code notes}, or null when there is nothing to export. */ + @Nullable + @VisibleForTesting + static Export buildExport(@NonNull Bundle args, @Nullable List notes) { + if (notes == null || notes.isEmpty()) return null; + if (args.getBoolean(ARG_SINGLE)) { + Note note = notes.get(0); + return new Export(titleOrStars(note.getTitle()), note.getValue()); + } + StringBuilder combined = new StringBuilder(); + for (int i = 0; i < notes.size(); i++) { + Note note = notes.get(i); + combined.append(titleOrStars(note.getTitle())).append("\n\n"); + if (note.getValue() != null) combined.append(note.getValue()); + if (i < notes.size() - 1) combined.append("\n\n\n===================\n\n"); + } + return new Export(exportTitle(args), combined.toString()); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // Initialize activity result launchers saveTxtLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == Activity.RESULT_OK - && result.getData() != null) { - Uri uri = result.getData().getData(); - - if (uri != null - && currentNoteTitle != null - && currentNoteContent != null) { - Schedulers.io() - .scheduleDirect(() -> - FileExportUtils.saveTxtToUri( - requireContext(), - uri, - currentNoteTitle, - currentNoteContent)); - } - } - dismiss(); // Dismiss after handling result - }); - + result -> onFileChosen(result, Format.TXT)); savePdfLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == Activity.RESULT_OK - && result.getData() != null) { - Uri uri = result.getData().getData(); - if (uri != null) { - FileExportUtils.savePdfToUri( - requireContext(), - uri, - currentNoteTitle, - currentNoteContent); - } - } - dismiss(); // Dismiss after handling result - }); - + result -> onFileChosen(result, Format.PDF)); saveHtmlLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == Activity.RESULT_OK - && result.getData() != null) { - Uri uri = result.getData().getData(); - if (uri != null) { - FileExportUtils.saveHtmlToUri( - requireContext(), + result -> onFileChosen(result, Format.HTML)); + } + + private enum Format { + TXT, + PDF, + HTML + } + + private Bundle args() { + Bundle args = getArguments(); + return args == null ? new Bundle() : args; + } + + private void onFileChosen(ActivityResult result, Format format) { + Uri uri = + result.getResultCode() == Activity.RESULT_OK && result.getData() != null + ? result.getData().getData() + : null; + if (uri != null) { + // Captured now: the dialog is gone before the file is written. + Context app = requireContext().getApplicationContext(); + Bundle args = new Bundle(args()); + List known = notes; + write(app, uri, format, args, known); + } + dismissAllowingStateLoss(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void write( + Context app, Uri uri, Format format, Bundle args, @Nullable List known) { + Single.fromCallable( + () -> { + List source = known != null ? known : loadNotes(app, args); + Export export = buildExport(args, source); + // Nothing left to export (the notes were deleted meanwhile): say so + // rather than + // writing an empty file that looks like a success. + if (export == null) return false; + switch (format) { + case PDF: + return FileExportUtils.savePdfToUri( + app, uri, export.title, export.content); + case HTML: + return FileExportUtils.saveHtmlToUri( + app, uri, - currentNoteTitle, - currentNoteContent, - mSelectedNotes); - } + export.title, + export.content, + args.getBoolean(ARG_SINGLE) ? null : source); + default: + return FileExportUtils.saveTxtToUri( + app, uri, export.title, export.content); } - dismiss(); // Dismiss after handling result + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ok -> + Toast.makeText( + app, + ok + ? R.string.fileSavedSuccessfully + : R.string.errorSavingFile, + Toast.LENGTH_SHORT) + .show(), + error -> { + Log.e(TAG, "export failed", error); + Toast.makeText(app, R.string.errorSavingFile, Toast.LENGTH_SHORT) + .show(); }); } + /** Reads the notes named in {@code args} back, in their order, skipping deleted ones. */ + @WorkerThread + private static List loadNotes(Context app, Bundle args) { + int[] ids = args.getIntArray(ARG_NOTE_IDS); + List result = new ArrayList<>(); + if (ids == null || ids.length == 0) return result; + AppDatabase db = + EntryPointAccessors.fromApplication(app, ShareEntryPoint.class).appDatabase(); + Map byId = new HashMap<>(); + for (int start = 0; start < ids.length; start += ID_CHUNK) { + List chunk = new ArrayList<>(); + for (int i = start; i < Math.min(ids.length, start + ID_CHUNK); i++) chunk.add(ids[i]); + for (Note note : db.noteDao().getNotesByIdsSync(chunk)) byId.put(note.getId(), note); + } + for (int id : ids) { + Note note = byId.get(id); + if (note != null) result.add(note); + } + return result; + } + + /** Runs {@code action} with the notes, reading them again first if the dialog was recreated. */ + private void withExport(ExportAction action) { + if (notes != null) { + Export export = buildExport(args(), notes); + if (export != null) action.run(export); + else showError(); + return; + } + if (pendingLoad != null && !pendingLoad.isDisposed()) return; + Context app = requireContext().getApplicationContext(); + Bundle args = new Bundle(args()); + pendingLoad = + Single.fromCallable(() -> loadNotes(app, args)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + loaded -> { + if (!isAdded()) return; + notes = loaded; + withExport(action); + }, + error -> { + Log.e(TAG, "loading notes failed", error); + if (isAdded()) showError(); + }); + } + + private interface ExportAction { + void run(Export export); + } + + private void showError() { + Toast.makeText(requireContext(), R.string.errorSavingFile, Toast.LENGTH_SHORT).show(); + dismissAllowingStateLoss(); + } + @Override public View onCreateView( @NonNull LayoutInflater inflater, @@ -168,40 +330,32 @@ public void setState(BottomSheetDialog dialog) { @Override public void initListeners() { - binding.saveToGoogleDrive.setOnClickListener(v -> { - prepareNoteData(); - String formattedContent = - FileExportUtils.formatNoteContent(currentNoteTitle, currentNoteContent); - FileExportUtils.saveToGoogleDrive(requireContext(), currentNoteTitle, formattedContent); - dismiss(); - }); - - binding.saveAsTxt.setOnClickListener(v -> { - prepareNoteData(); - Intent intent = FileExportUtils.createSaveTxtIntent(currentNoteTitle); - saveTxtLauncher.launch(intent); - }); - - binding.saveAsPdf.setOnClickListener(v -> { - prepareNoteData(); - Intent intent = FileExportUtils.createSavePdfIntent(currentNoteTitle); - savePdfLauncher.launch(intent); - }); - - binding.saveAsHtml.setOnClickListener(v -> { - prepareNoteData(); - Intent intent = FileExportUtils.createSaveHtmlIntent(currentNoteTitle); - saveHtmlLauncher.launch(intent); - }); - - binding.shareViaOtherApps.setOnClickListener(v -> { - prepareNoteData(); - String formattedContent = - FileExportUtils.formatNoteContent(currentNoteTitle, currentNoteContent); - FileExportUtils.shareViaOtherApps( - requireActivity(), currentNoteTitle, formattedContent); - dismiss(); - }); + binding.saveToGoogleDrive.setOnClickListener(v -> + withExport(export -> { + String formattedContent = + FileExportUtils.formatNoteContent(export.title, export.content); + FileExportUtils.saveToGoogleDrive( + requireContext(), export.title, formattedContent); + dismiss(); + })); + + binding.saveAsTxt.setOnClickListener(v -> + saveTxtLauncher.launch(FileExportUtils.createSaveTxtIntent(exportTitle(args())))); + + binding.saveAsPdf.setOnClickListener(v -> + savePdfLauncher.launch(FileExportUtils.createSavePdfIntent(exportTitle(args())))); + + binding.saveAsHtml.setOnClickListener(v -> + saveHtmlLauncher.launch(FileExportUtils.createSaveHtmlIntent(exportTitle(args())))); + + binding.shareViaOtherApps.setOnClickListener(v -> + withExport(export -> { + String formattedContent = + FileExportUtils.formatNoteContent(export.title, export.content); + FileExportUtils.shareViaOtherApps( + requireActivity(), export.title, formattedContent); + dismiss(); + })); } @Override @@ -210,53 +364,18 @@ public void onDismiss(@NonNull DialogInterface dialog) { clearListeners(); } + @Override + public void onDestroy() { + super.onDestroy(); + if (pendingLoad != null) pendingLoad.dispose(); + } + private void clearListeners() { + if (binding == null) return; binding.saveToGoogleDrive.setOnClickListener(null); binding.saveAsTxt.setOnClickListener(null); binding.saveAsPdf.setOnClickListener(null); binding.saveAsHtml.setOnClickListener(null); binding.shareViaOtherApps.setOnClickListener(null); } - - private void prepareNoteData() { - if (mNote != null) { - currentNoteTitle = - (mNote.getTitle() == null || mNote.getTitle().isEmpty()) - ? "***" - : mNote.getTitle(); - currentNoteContent = mNote.getValue(); - - } else if (mSelectedNotes != null && !mSelectedNotes.isEmpty()) { - - // Set title based on whether this is data export or regular share - if (isDataExport) { - currentNoteTitle = "Data_Export"; - } else { - currentNoteTitle = "Multiple_Notes"; - } - - StringBuilder combinedContent = new StringBuilder(); - - for (int i = 0; i < mSelectedNotes.size(); i++) { - Note note = mSelectedNotes.get(i); - String noteTitle = - (note.getTitle() == null || note.getTitle().isEmpty()) - ? "***" - : note.getTitle(); - - combinedContent.append(noteTitle).append("\n\n"); - if (note.getValue() != null) { - combinedContent.append(note.getValue()); - } - if (i < mSelectedNotes.size() - 1) { - combinedContent.append("\n\n\n===================\n\n"); - } - } - currentNoteContent = combinedContent.toString(); - - } else { - currentNoteTitle = "***"; - currentNoteContent = ""; - } - } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java index d2b58153..6647caae 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java +++ b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java @@ -10,6 +10,7 @@ import android.net.Uri; import android.util.Log; import android.widget.Toast; +import androidx.annotation.WorkerThread; import androidx.core.content.FileProvider; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Note; @@ -144,8 +145,12 @@ public static void shareViaOtherApps(Context context, String noteTitle, String n context.startActivity(chooser); } - /** Save TXT content to selected URI */ - public static void saveTxtToUri( + /** + * Saves TXT content to the selected URI. Runs on a background thread and shows nothing; returns + * whether the file was written. + */ + @WorkerThread + public static boolean saveTxtToUri( Context context, Uri uri, String noteTitle, String noteContent) { try { String formattedContent = formatNoteContent(noteTitle, noteContent); @@ -162,28 +167,21 @@ public static void saveTxtToUri( outputStream.write(contentBytes); outputStream.flush(); outputStream.close(); - - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); - } else { - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); + return true; } + return false; } catch (Exception e) { Log.e(TAG, "Error saving TXT file", e); - Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) - .show(); + return false; } } - /** Save PDF content to selected URI */ - public static void savePdfToUri( + /** + * Saves PDF content to the selected URI. Runs on a background thread and shows nothing; returns + * whether the file was written. + */ + @WorkerThread + public static boolean savePdfToUri( Context context, Uri uri, String noteTitle, String noteContent) { try { PdfDocument pdfDocument = new PdfDocument(); @@ -262,36 +260,30 @@ public static void savePdfToUri( OutputStream outputStream = context.getContentResolver().openOutputStream(uri); Log.d(TAG, "PDF OutputStream opened: " + (outputStream != null)); + boolean written = false; if (outputStream != null) { pdfDocument.writeTo(outputStream); outputStream.flush(); outputStream.close(); - - Log.d(TAG, "PDF written successfully"); - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); + written = true; } else { Log.e(TAG, "PDF OutputStream is null"); - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); } pdfDocument.close(); - + return written; } catch (Exception e) { - Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) - .show(); + Log.e(TAG, "Error saving PDF file", e); + return false; } } - /** Save HTML content to selected URI */ - public static void saveHtmlToUri( + /** + * Saves HTML content to the selected URI. Runs on a background thread and shows nothing; + * returns whether the file was written. + */ + @WorkerThread + public static boolean saveHtmlToUri( Context context, Uri uri, String noteTitle, String noteContent, List notes) { try { String htmlContent = @@ -310,23 +302,12 @@ public static void saveHtmlToUri( outputStream.write(contentBytes); outputStream.flush(); outputStream.close(); - - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); - } else { - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); + return true; } + return false; } catch (Exception e) { Log.e(TAG, "Error saving HTML file", e); - Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) - .show(); + return false; } } diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java new file mode 100644 index 00000000..46c8393e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java @@ -0,0 +1,74 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; +import com.pasich.mynotes.data.model.Note; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** The share dialog keeps what it needs in its arguments and never exports nothing silently. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ShareOptionsDialogArgsTest { + + private static Note note(int id, String title, String value) { + Note note = new Note().create(title, value, 1L, ""); + note.setId(id); + return note; + } + + @Test + public void recreatedDialog_keepsTheNoteIdsAndFlags() { + ShareOptionsDialog original = + ShareOptionsDialog.newInstance( + Arrays.asList(note(3, "A", "a"), note(9, "B", "b")), true); + + Fragment rebuilt = + new FragmentFactory() + .instantiate( + getClass().getClassLoader(), ShareOptionsDialog.class.getName()); + rebuilt.setArguments(new Bundle(original.requireArguments())); + + Bundle args = rebuilt.requireArguments(); + assertThat(args.getIntArray("noteIds")).asList().containsExactly(3, 9).inOrder(); + assertThat(ShareOptionsDialog.exportTitle(args)).isEqualTo("Data_Export"); + } + + @Test + public void singleNote_titleComesFromTheArguments() { + Bundle args = ShareOptionsDialog.arguments(List.of(note(1, "", "text")), true, false); + + assertThat(ShareOptionsDialog.exportTitle(args)).isEqualTo("***"); + ShareOptionsDialog.Export export = + ShareOptionsDialog.buildExport(args, List.of(note(1, "", "text"))); + assertThat(export.title).isEqualTo("***"); + assertThat(export.content).isEqualTo("text"); + } + + @Test + public void severalNotes_areJoinedInOrder() { + List notes = Arrays.asList(note(1, "One", "1"), note(2, "Two", "2")); + Bundle args = ShareOptionsDialog.arguments(notes, false, false); + + ShareOptionsDialog.Export export = ShareOptionsDialog.buildExport(args, notes); + + assertThat(export.title).isEqualTo("Multiple_Notes"); + assertThat(export.content).isEqualTo("One\n\n1\n\n\n===================\n\nTwo\n\n2"); + } + + @Test + public void notesGoneMeanwhile_nothingToExport() { + Bundle args = ShareOptionsDialog.arguments(List.of(note(1, "A", "a")), false, true); + + assertThat(ShareOptionsDialog.buildExport(args, Collections.emptyList())).isNull(); + assertThat(ShareOptionsDialog.buildExport(args, null)).isNull(); + } +} From 21e4f7d8d2f5e4bfd98fa90b9975ee3900bc224d Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:33:47 +0200 Subject: [PATCH 24/52] perf(sync): verify attachments once per sync, through the digest cache 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. --- .../data/sync/GoogleDriveSyncBackend.java | 23 +++++- .../mynotes/data/sync/RoomSyncStore.java | 20 +++++ .../pasich/mynotes/data/sync/SyncService.java | 28 ++++--- .../pasich/mynotes/data/sync/SyncStore.java | 20 +++++ .../data/sync/GoogleDriveSyncBackendTest.java | 48 ++++++++++++ ...omSyncStoreAttachmentVerificationTest.java | 75 +++++++++++++++++++ .../mynotes/data/sync/SyncServiceTest.java | 31 ++++++++ 7 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java b/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java index 5e986309..4be1d27e 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java @@ -116,6 +116,16 @@ public final class GoogleDriveSyncBackend implements SyncBackend { */ private final Map verifiedAttachments = new HashMap<>(); + /** + * Candidate listings already made during this sync, keyed by root and hash. + * + *

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> candidateListings = new HashMap<>(); + public GoogleDriveSyncBackend(@NonNull String accessToken) { this(accessToken, DEFAULT_API, DEFAULT_UPLOAD, Clock.systemUTC(), new SyncBundleCodec()); } @@ -667,6 +677,11 @@ private AttachmentCandidate( @NonNull private List listAttachmentCandidates( @NonNull String folderId, @NonNull String sha256) throws IOException { + String listingKey = memoKey(folderId, sha256); + List listed = candidateListings.get(listingKey); + if (listed != null) { + return listed; + } JsonArray files = listFiles( ownedFilesQuery(folderId, PROPERTY_ATTACHMENT_SHA256, sha256), @@ -684,7 +699,9 @@ private List 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 result = Collections.unmodifiableList(candidates); + candidateListings.put(listingKey, result); + return result; } /** @@ -1148,9 +1165,13 @@ 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. + candidateListings.remove(memoKey(folderId, sha256)); try { uploadStream(folderId, sha256, MIME_BINARY, content, sizeBytes); + candidateListings.remove(memoKey(folderId, sha256)); } catch (IOException uploadFailure) { + candidateListings.remove(memoKey(folderId, sha256)); // Attachment identity is its SHA-256. A successful request whose response was lost is // confirmed by discovery, not repeated with an already-consumed stream. if (!mayHaveCommitted(uploadFailure) diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index f8b721f7..af6e3af2 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -1725,6 +1725,26 @@ public boolean hasAttachment(@NonNull String sha256) { return resolveLocalAttachment(sha256) != null; } + /** + * Answers from the hash cache the snapshot build already uses, so an untouched blob is not read + * and hashed again on every sync. The cache keys a digest by path, size and modification time; + * any rewrite of the file is hashed afresh. + */ + @Override + public boolean hasVerifiedAttachment(@NonNull String sha256, @Nullable Long expectedSize) { + File file = resolveLocalAttachment(sha256); + if (file == null || (expectedSize != null && file.length() != expectedSize)) { + return false; + } + try { + return sha256.equals(hashCache.sha256(file, attachmentHasher::sha256)); + } catch (IOException unreadable) { + return false; + } finally { + hashCache.flush(); + } + } + @Nullable @Override public SnapshotProblem describeMissingAttachment(@NonNull String sha256) { diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java index e6fe69e8..c602d01d 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java @@ -414,21 +414,25 @@ private void synchronizeAttachments( } if (store.hasAttachment(hash)) { Long expectedSize = expectedSizes.get(hash); - // Index lookup only; the bytes are checked once, below. + // Index lookup only; the backend remembers the listing, so the verification + // below does not list the same blob again. boolean remotePresent = backend.hasAttachment(hash); - try { - verifyAttachment(hash, expectedSize, store.readAttachment(hash)); - } catch (IOException localError) { + // Through the store's digest cache: reading and hashing every local blob on every + // sync, unchanged or not, was the bulk of an idle sync's work. + if (!store.hasVerifiedAttachment(hash, expectedSize)) { if (!remotePresent) { - throw localError; + // Nothing to repair it from. Read it in full, which raises the precise + // failure — missing, wrong size or wrong digest — for the user to see. + verifyAttachment(hash, expectedSize, store.readAttachment(hash)); + } else { + // The local copy is missing or corrupt; repair it from the remote blob, + // which copyVerified refuses to accept unless it hashes correctly. + copyVerified( + hash, + expectedSize, + backend.readAttachment(hash), + store::writeAttachment); } - // The local copy is missing or corrupt; repair it from the remote blob, - // which copyVerified refuses to accept unless it hashes correctly. - copyVerified( - hash, - expectedSize, - backend.readAttachment(hash), - store::writeAttachment); } // Drive is untrusted: a matching appProperty is only a claim. The blob is read // and hashed exactly once per sync, and the result is remembered, so publishing diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java index 71ecc652..8f1c4701 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java @@ -98,6 +98,26 @@ default boolean hasDurableAttachment(@NonNull String sha256, long sizeBytes) return false; } + /** + * True when the local blob exists and its bytes hash to {@code sha256}. + * + *

The default reads and hashes the whole blob. A store that already knows the digest of an + * unchanged file should answer from that instead: the service asks this for every attachment on + * every sync, and re-hashing an untouched library each time dominated an idle sync. + * + * @param expectedSize the declared size, or null to accept any. + */ + default boolean hasVerifiedAttachment(@NonNull String sha256, @Nullable Long expectedSize) + throws IOException { + // Missing, unreadable and corrupt all mean the same here: not a copy to rely on. + try (InputStream input = readAttachment(sha256)) { + VerifyingInputStream.verify(input, sha256, expectedSize); + return true; + } catch (IOException unusable) { + return false; + } + } + /** Opens one complete local attachment. The caller closes the returned stream. */ @NonNull InputStream readAttachment(@NonNull String sha256) throws IOException; diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java index 896485c1..8a4f292a 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java @@ -938,6 +938,37 @@ public void oneSyncListsTheRootFoldersOnce() throws Exception { assertThat(server.folderListings() - before).isEqualTo(1); } + @Test + public void existenceAndVerificationShareOneListingOfTheBlob() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + backend().writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + GoogleDriveSyncBackend backend = backend(); + int before = server.listingsMentioning(hash); + + assertThat(backend.hasAttachment(hash)).isTrue(); + assertThat(backend.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + + // The service asks both questions for every attachment on every sync; each used to list + // the root again. + assertThat(server.listingsMentioning(hash) - before).isEqualTo(1); + } + + @Test + public void aBlobUploadedAfterAListingIsSeenByTheNextQuestion() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + byte[] other = "other".getBytes(StandardCharsets.UTF_8); + backend().writeAttachment(sha256(other), other.length, new ByteArrayInputStream(other)); + GoogleDriveSyncBackend backend = backend(); + // The blob is listed, and found absent, before this same backend uploads it. + assertThat(backend.hasAttachment(hash)).isFalse(); + + backend.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + + assertThat(backend.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + } + @Test public void anOversizedCandidateIsSkippedRatherThanFailingTheSync() throws Exception { // A Drive object larger than the ceiling and tagged with the expected hash: the ceiling @@ -1277,6 +1308,7 @@ private static final class FakeDriveServer implements AutoCloseable { private volatile boolean running = true; private volatile CyclicBarrier emptyRootListingBarrier; private final AtomicInteger folderListings = new AtomicInteger(); + private final List listingQueries = new ArrayList<>(); private final Map mediaReads = new ConcurrentHashMap<>(); private final List deletedFileIds = java.util.Collections.synchronizedList(new ArrayList<>()); @@ -1359,6 +1391,17 @@ int folderListings() { return folderListings.get(); } + /** How many file listings named {@code text} in their query. */ + int listingsMentioning(String text) { + int total = 0; + synchronized (listingQueries) { + for (String query : listingQueries) { + if (query.contains(text)) total++; + } + } + return total; + } + /** How many times the bytes of the blob carrying {@code sha256} were downloaded. */ int mediaReadsOfAttachment(String sha256) { int total = 0; @@ -1682,6 +1725,11 @@ && ownedFolderCount() == 0) { && query.contains("mimeType = 'application/vnd.google-apps.folder'")) { folderListings.incrementAndGet(); } + if (query != null) { + synchronized (listingQueries) { + listingQueries.add(query); + } + } JsonArray array = new JsonArray(); for (DriveFile file : files.values()) { if (matchesQuery(file, query)) { diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java new file mode 100644 index 00000000..c76f09ce --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java @@ -0,0 +1,75 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import androidx.room.Room; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Verifying a local blob goes through the digest cache rather than re-hashing it every sync. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreAttachmentVerificationTest { + + private AppDatabase db; + private RoomSyncStore store; + private final int[] hashes = {0}; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + store = + new RoomSyncStore( + context, + db, + mock(PreferenceHelper.class), + (ignored, attachment) -> null, + file -> { + hashes[0]++; + return Sha256.of(file); + }); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void anUnchangedBlobIsHashedOnceAcrossRepeatedVerifications() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = Sha256.of(bytes); + store.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + + assertThat(store.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + assertThat(store.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + assertThat(store.hasVerifiedAttachment(hash, null)).isTrue(); + + assertThat(hashes[0]).isEqualTo(1); + } + + @Test + public void aBlobOfTheWrongSizeOrMissingIsNotVerified() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = Sha256.of(bytes); + store.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + + assertThat(store.hasVerifiedAttachment(hash, (long) bytes.length + 1)).isFalse(); + assertThat(store.hasVerifiedAttachment(Sha256.of("absent".getBytes()), null)).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java index bbeae446..0c1c9aad 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java @@ -156,6 +156,25 @@ public void sync_skipsUploadingAttachmentWhenRemoteBlobAlreadyExists() throws Ex .inOrder(); } + @Test + public void sync_trustsTheStoresDigestCacheInsteadOfReHashingEveryLocalBlob() throws Exception { + SyncRecord local = note(TEN, "Local with an unchanged attachment"); + byte[] bytes = "local attachment".getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + FakeStore store = new FakeStore(snapshot(local)); + store.attachmentHashes = Collections.singletonList(hash); + store.attachments.put(hash, bytes); + store.verifiedFromCache.add(hash); + FakeBackend backend = new FakeBackend(SyncSnapshot.empty()); + backend.attachments.put(hash, bytes); + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + // Every local blob used to be read in full and hashed on every sync, cache or not. + assertThat(store.readAttachmentCalls).isEqualTo(0); + } + @Test public void sync_repairsCorruptLocalAttachmentFromRemote() throws Exception { SyncRecord local = note(TEN, "Local with corrupt attachment"); @@ -596,8 +615,20 @@ public boolean hasAttachment(String sha256) { return attachments.containsKey(sha256); } + /** Blobs the store vouches for from its digest cache, as RoomSyncStore does. */ + private final java.util.Set verifiedFromCache = new java.util.HashSet<>(); + + private int readAttachmentCalls; + + @Override + public boolean hasVerifiedAttachment(String sha256, Long expectedSize) throws IOException { + return verifiedFromCache.contains(sha256) + || SyncStore.super.hasVerifiedAttachment(sha256, expectedSize); + } + @Override public InputStream readAttachment(String sha256) throws IOException { + readAttachmentCalls++; byte[] bytes = attachments.get(sha256); if (bytes == null) { throw new IOException("missing local attachment"); From aa5cabb97e0cdebf7ccc56007d47b9c6829dad99 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:35:04 +0200 Subject: [PATCH 25/52] fix(reminders): arm the alarm with the write, not in the sheet's callback 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. --- .../dialogs/ReminderPickerBottomSheet.java | 115 ++++++++++-------- .../TaskReminderPickerBottomSheet.java | 92 +++++++------- 2 files changed, 112 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java index cbdd8e87..df8b3969 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ReminderPickerBottomSheet.java @@ -1,6 +1,8 @@ package com.pasich.mynotes.ui.view.dialogs; import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Build; @@ -505,65 +507,76 @@ private void checkPermissionsAndSave() { saveReminder(); } + @SuppressLint("CheckResult") private void saveReminder() { if (selectedTime == null) return; final long time = selectedTime; final String repeat = selectedRule(time).serialize(); - - disposables.add( - dataManager - .updateNoteReminderFull(noteId, time, repeat, selectedIntervalMinutes) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) - .subscribe( - () -> { - Note tempNote = new Note(); - tempNote.setId(noteId); - if (currentNote != null) { - tempNote.setTitle(currentNote.getTitle()); - tempNote.setValue(currentNote.getValue()); - } - tempNote.setReminderTime(time); - tempNote.setReminderRepeat(repeat); - tempNote.setReminderIntervalMinutes(selectedIntervalMinutes); - // Redefined: a snooze or repeating notification of the old - // reminder no longer applies. - ReminderManager.cancelReminder(requireContext(), noteId); - boolean exact = - ReminderManager.scheduleReminder( - requireContext(), tempNote); - if (!exact) askForExactAlarms(); - - Bundle result = new Bundle(); - result.putBoolean("hasReminder", true); - result.putLong("reminderTime", time); - getParentFragmentManager() - .setFragmentResult("reminderChanged", result); - - dismiss(); - }, - e -> Log.e(TAG, "save failed", e))); + final int nag = selectedIntervalMinutes; + final int id = noteId; + final Context app = requireContext().getApplicationContext(); + + Note tempNote = new Note(); + tempNote.setId(id); + if (currentNote != null) { + tempNote.setTitle(currentNote.getTitle()); + tempNote.setValue(currentNote.getValue()); + } + tempNote.setReminderTime(time); + tempNote.setReminderRepeat(repeat); + tempNote.setReminderIntervalMinutes(nag); + + // The alarm is armed right after the write on the io thread, not in a callback the sheet's + // teardown can cancel: a stored reminder always has its alarm. Not tied to the sheet's + // disposables for the same reason; the sheet part checks it is still there. + dataManager + .updateNoteReminderFull(id, time, repeat, nag) + .andThen( + io.reactivex.Single.fromCallable(() -> { + // Redefined: a snooze or repeating notification of the old reminder + // no longer applies. + ReminderManager.cancelReminder(app, id); + return ReminderManager.scheduleReminder(app, tempNote); + })) + .subscribeOn(io.reactivex.schedulers.Schedulers.io()) + .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) + .subscribe( + exact -> { + if (!isAdded()) return; + if (!exact) askForExactAlarms(); + + Bundle result = new Bundle(); + result.putBoolean("hasReminder", true); + result.putLong("reminderTime", time); + getParentFragmentManager().setFragmentResult("reminderChanged", result); + + dismissAllowingStateLoss(); + }, + e -> Log.e(TAG, "save failed", e)); } + @SuppressLint("CheckResult") private void deleteReminder() { - disposables.add( - dataManager - .clearReminder(noteId) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) - .subscribe( - () -> { - ReminderManager.cancelReminder(requireContext(), noteId); - - Bundle result = new Bundle(); - result.putBoolean("hasReminder", false); - getParentFragmentManager() - .setFragmentResult("reminderChanged", result); - - dismiss(); - }, - e -> Log.e(TAG, "delete failed", e))); + final int id = noteId; + final Context app = requireContext().getApplicationContext(); + dataManager + .clearReminder(id) + .andThen( + io.reactivex.Completable.fromAction( + () -> ReminderManager.cancelReminder(app, id))) + .subscribeOn(io.reactivex.schedulers.Schedulers.io()) + .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) + .subscribe( + () -> { + if (!isAdded()) return; + Bundle result = new Bundle(); + result.putBoolean("hasReminder", false); + getParentFragmentManager().setFragmentResult("reminderChanged", result); + + dismissAllowingStateLoss(); + }, + e -> Log.e(TAG, "delete failed", e)); } /** diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/TaskReminderPickerBottomSheet.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/TaskReminderPickerBottomSheet.java index ac951a9a..be937592 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/TaskReminderPickerBottomSheet.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/TaskReminderPickerBottomSheet.java @@ -1,6 +1,8 @@ package com.pasich.mynotes.ui.view.dialogs; import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Build; @@ -32,7 +34,7 @@ import com.pasich.mynotes.utils.reminder.ReminderManager; import com.pasich.mynotes.utils.reminder.TaskReminderManager; import dagger.hilt.android.AndroidEntryPoint; -import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.Completable; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; @@ -54,7 +56,6 @@ public class TaskReminderPickerBottomSheet extends BottomSheetDialogFragment { private String taskTitle; private Long selectedTime = null; private int selectedIntervalMinutes = 0; - private final CompositeDisposable disposables = new CompositeDisposable(); private View selectedTimeCard; private TextView selectedTimeDisplay; @@ -296,56 +297,59 @@ private void checkPermissionsAndSave() { saveReminder(); } + @SuppressLint("CheckResult") private void saveReminder() { if (selectedTime == null) return; final long time = selectedTime; final int interval = selectedIntervalMinutes; + final Context app = requireContext().getApplicationContext(); - disposables.add( - dataManager - .setTaskReminderFull(taskId, time, interval) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) - .subscribe( - () -> { - Task tempTask = new Task(); - tempTask.setId(taskId); - tempTask.setTitle(taskTitle); - tempTask.setReminderTime(time); - tempTask.setReminderIntervalMinutes(interval); - TaskReminderManager.scheduleReminder( - requireContext(), tempTask); + Task tempTask = new Task(); + tempTask.setId(taskId); + tempTask.setTitle(taskTitle); + tempTask.setReminderTime(time); + tempTask.setReminderIntervalMinutes(interval); - Bundle result = new Bundle(); - result.putBoolean("saved", true); - getParentFragmentManager() - .setFragmentResult("taskReminderChanged", result); - dismiss(); - }, - e -> Log.e(TAG, "save failed", e))); + // The alarm is armed right after the write on the io thread, not in a callback the sheet's + // teardown can cancel, so a stored reminder always has its alarm. Not tied to the sheet's + // disposables for the same reason; the sheet part checks it is still there. + dataManager + .setTaskReminderFull(taskId, time, interval) + .andThen( + Completable.fromAction( + () -> TaskReminderManager.scheduleReminder(app, tempTask))) + .subscribeOn(io.reactivex.schedulers.Schedulers.io()) + .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) + .subscribe( + () -> { + if (!isAdded()) return; + Bundle result = new Bundle(); + result.putBoolean("saved", true); + getParentFragmentManager() + .setFragmentResult("taskReminderChanged", result); + dismissAllowingStateLoss(); + }, + e -> Log.e(TAG, "save failed", e)); } + @SuppressLint("CheckResult") private void deleteReminder() { - disposables.add( - dataManager - .clearTaskReminder(taskId) - .subscribeOn(io.reactivex.schedulers.Schedulers.io()) - .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) - .subscribe( - () -> { - TaskReminderManager.cancelReminder(requireContext(), taskId); - Bundle result = new Bundle(); - result.putBoolean("saved", false); - getParentFragmentManager() - .setFragmentResult("taskReminderChanged", result); - dismiss(); - }, - e -> Log.e(TAG, "delete failed", e))); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - disposables.clear(); + final int id = taskId; + final Context app = requireContext().getApplicationContext(); + dataManager + .clearTaskReminder(id) + .andThen(Completable.fromAction(() -> TaskReminderManager.cancelReminder(app, id))) + .subscribeOn(io.reactivex.schedulers.Schedulers.io()) + .observeOn(io.reactivex.android.schedulers.AndroidSchedulers.mainThread()) + .subscribe( + () -> { + if (!isAdded()) return; + Bundle result = new Bundle(); + result.putBoolean("saved", false); + getParentFragmentManager() + .setFragmentResult("taskReminderChanged", result); + dismissAllowingStateLoss(); + }, + e -> Log.e(TAG, "delete failed", e)); } } From c0de9e43ff2d4800be8befcb39e0c3fcaeb06fec Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:37:11 +0200 Subject: [PATCH 26/52] perf(backup): stream backup archives instead of holding them in memory 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. --- .../utils/backup/local/LocalBackup.java | 83 +++++++++----- .../utils/backup/local/ZipBackupHelper.java | 101 ++++++++++++------ .../backup/local/ZipBackupHelperTest.java | 45 ++++++++ 3 files changed, 170 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java b/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java index 85078491..84edb28a 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java @@ -4,24 +4,35 @@ import android.net.Uri; import android.os.ParcelFileDescriptor; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import com.pasich.mynotes.utils.backup.BackupCacheHelper; import com.pasich.mynotes.utils.backup.ScramblerBackupHelper; import com.pasich.mynotes.utils.backup.models.JsonBackup; import dagger.hilt.android.qualifiers.ApplicationContext; +import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import javax.inject.Inject; import javax.inject.Singleton; +/** + * Reads and writes backup files the user picked. + * + *

Both directions do file I/O over a whole library, attachments included, and must be called off + * the main thread. + */ @Singleton public class LocalBackup implements LocalBackupHelper { private static final String TAG = "LocalBackup"; + private static final int COPY_BUFFER = 64 * 1024; private final Context mContext; @Inject @@ -29,60 +40,80 @@ public LocalBackup(@ApplicationContext Context context) { this.mContext = context; } + @WorkerThread @Override public boolean writeBackupLocalFile(BackupCacheHelper serviceCache, Uri uriLocalFile) { if (!checkServiceCache(serviceCache)) return false; + File zip = null; try { - - File zip = ZipBackupHelper.writeZipBackup(mContext, serviceCache.getJsonBackup()); + zip = ZipBackupHelper.writeZipBackup(mContext, serviceCache.getJsonBackup()); try (ParcelFileDescriptor desc = mContext.getContentResolver().openFileDescriptor(uriLocalFile, "w")) { - assert desc != null; - try (FileOutputStream fos = new FileOutputStream(desc.getFileDescriptor())) { - - fos.write(Files.readAllBytes(zip.toPath())); - return true; + if (desc == null) { + Log.e(TAG, "The picked document could not be opened for writing"); + return false; + } + try (InputStream in = new FileInputStream(zip); + FileOutputStream out = new FileOutputStream(desc.getFileDescriptor())) { + // In chunks: the archive carries every attachment and can run to gigabytes. + copy(in, out); + out.flush(); } } - - } catch (Exception e) { + serviceCache.setJsonBackup(null); + return true; + } catch (Exception | OutOfMemoryError e) { Log.e(TAG, "Failed to write ZIP backup", e); return false; + } finally { + if (zip != null && zip.exists() && !zip.delete()) { + Log.w(TAG, "Could not remove the temporary backup archive"); + } } } + @WorkerThread @Override public JsonBackup readBackupLocalFile(Uri uriLocalFile) { try { - byte[] raw = readBytes(uriLocalFile); + boolean zip; + try (InputStream is = mContext.getContentResolver().openInputStream(uriLocalFile)) { + if (is == null) return new JsonBackup().error(); + // Only the first two bytes decide; the archive itself is streamed below, never + // held in memory whole. + zip = ZipBackupHelper.isZip(is); + } - if (ZipBackupHelper.isZip(raw)) { + if (zip) { return ZipBackupHelper.readZipBackup(mContext, uriLocalFile); } - return ScramblerBackupHelper.decodeString(new String(raw, StandardCharsets.UTF_8)); + return ScramblerBackupHelper.decodeString(readText(uriLocalFile)); - } catch (Exception e) { + } catch (Exception | OutOfMemoryError e) { Log.e(TAG, "Failed to read backup file", e); return new JsonBackup().error(); } } - private byte[] readBytes(Uri uri) throws IOException { - try (InputStream is = mContext.getContentResolver().openInputStream(uri); - ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - - if (is == null) return new byte[0]; - - byte[] data = new byte[4096]; - int nRead; - while ((nRead = is.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, nRead); - } + /** Reads a legacy, JSON-only backup; these carry no attachments and stay small. */ + @NonNull + private String readText(Uri uri) throws IOException { + try (InputStream raw = mContext.getContentResolver().openInputStream(uri)) { + if (raw == null) return ""; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + copy(new BufferedInputStream(raw), buffer); + return buffer.toString(StandardCharsets.UTF_8.name()); + } + } - return buffer.toByteArray(); + static void copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException { + byte[] chunk = new byte[COPY_BUFFER]; + int n; + while ((n = in.read(chunk)) != -1) { + out.write(chunk, 0, n); } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelper.java b/app/src/main/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelper.java index 2005ea08..2b5f35c8 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelper.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelper.java @@ -13,13 +13,16 @@ import com.pasich.mynotes.extendedEditor.attach.AttachmentStorage; import com.pasich.mynotes.extendedEditor.attach.AttachmentUrl; import com.pasich.mynotes.utils.backup.models.JsonBackup; +import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -48,50 +51,82 @@ public static boolean isZip(byte[] data) { return data.length > 2 && data[0] == 0x50 && data[1] == 0x4B; } - /** Create ZIP backup */ - public static File writeZipBackup(Context ctx, JsonBackup backup) throws Exception { + /** + * Detects ZIP by the magic header "PK", reading only the first bytes of the stream. + * + *

The stream is left part-read; the caller opens the document again to parse it. + */ + public static boolean isZip(@NonNull InputStream in) throws IOException { + int first = in.read(); + int second = first == -1 ? -1 : in.read(); + return first == 0x50 && second == 0x4B; + } + /** + * Creates the ZIP backup in the cache directory. + * + *

Everything is streamed — the JSON straight from Gson, each attachment in chunks — so the + * size of the library, not of the heap, is the limit. + */ + public static File writeZipBackup(Context ctx, JsonBackup backup) throws IOException { File zipFile = new File(ctx.getCacheDir(), FILE_NAME_BACKUP_MNBKN); - ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile)); - - // Write backup.json - zos.putNextEntry(new ZipEntry(FILE_NAME_BACKUP)); - String json = new Gson().toJson(backup); - zos.write(json.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - - // Write attachments (full structure) - File baseDir = new File(ctx.getFilesDir(), ATTACHMENTS_BASE_DIR); + try { + writeZipBackup(backup, new File(ctx.getFilesDir(), ATTACHMENTS_BASE_DIR), zipFile); + } catch (IOException | RuntimeException e) { + if (zipFile.exists() && !zipFile.delete()) { + Log.w(TAG, "Could not remove a partly written backup archive"); + } + throw e; + } + return zipFile; + } - if (baseDir.exists()) { - File[] noteDirs = baseDir.listFiles(); + /** Filesystem-only core of {@link #writeZipBackup(Context, JsonBackup)}. */ + static void writeZipBackup( + @NonNull JsonBackup backup, @NonNull File attachmentsDir, @NonNull File zipFile) + throws IOException { + try (ZipOutputStream zos = + new ZipOutputStream( + new BufferedOutputStream(new FileOutputStream(zipFile), 64 * 1024))) { - if (noteDirs != null) { - for (File noteDir : noteDirs) { + // Write backup.json + zos.putNextEntry(new ZipEntry(FILE_NAME_BACKUP)); + Writer writer = new OutputStreamWriter(zos, StandardCharsets.UTF_8); + new Gson().toJson(backup, writer); + // Flushed, not closed: closing the writer would close the archive. + writer.flush(); + zos.closeEntry(); - if (!noteDir.isDirectory()) continue; - File[] files = noteDir.listFiles(); + // Write attachments (full structure) + File[] noteDirs = attachmentsDir.listFiles(); + if (noteDirs == null) return; - if (files == null) continue; + byte[] chunk = new byte[64 * 1024]; + for (File noteDir : noteDirs) { + if (!noteDir.isDirectory()) continue; + File[] files = noteDir.listFiles(); + if (files == null) continue; - for (File attachment : files) { - String entryName = - ATTACHMENTS_BASE_DIR - + "/" - + noteDir.getName() - + "/" - + attachment.getName(); + for (File attachment : files) { + if (!attachment.isFile()) continue; + String entryName = + ATTACHMENTS_BASE_DIR + + "/" + + noteDir.getName() + + "/" + + attachment.getName(); - zos.putNextEntry(new ZipEntry(entryName)); - zos.write(Files.readAllBytes(attachment.toPath())); - zos.closeEntry(); + zos.putNextEntry(new ZipEntry(entryName)); + try (InputStream in = new FileInputStream(attachment)) { + int n; + while ((n = in.read(chunk)) != -1) { + zos.write(chunk, 0, n); + } } + zos.closeEntry(); } } } - - zos.close(); - return zipFile; } /** diff --git a/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java b/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java index 24c7e3e7..ebc46640 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java @@ -105,6 +105,51 @@ public void refusesAnArchiveThatUnpacksPastTheCeiling() throws Exception { assertThat(backup.isError()).isTrue(); } + @Test + public void writtenArchive_readsBackWithItsAttachments() throws Exception { + File attachments = temporaryFolder.newFolder("files", "attachments"); + File noteDir = new File(attachments, "note_3"); + assertThat(noteDir.mkdirs()).isTrue(); + // Larger than one copy chunk, so the streamed copy is exercised past its first read. + byte[] photo = new byte[200 * 1024 + 17]; + for (int i = 0; i < photo.length; i++) photo[i] = (byte) i; + Files.write(new File(noteDir, "photo.jpg").toPath(), photo); + File zipFile = new File(temporaryFolder.getRoot(), "backup.mnbkn"); + File staging = new File(temporaryFolder.getRoot(), "staging/attachments"); + + ZipBackupHelper.writeZipBackup(new JsonBackup(), attachments, zipFile); + JsonBackup backup; + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zipFile.toPath()))) { + backup = ZipBackupHelper.readZipBackup(zis, staging); + } + + assertThat(backup.isError()).isFalse(); + assertThat(Files.readAllBytes(new File(new File(staging, "note_3"), "photo.jpg").toPath())) + .isEqualTo(photo); + } + + @Test + public void writesAnArchiveWithNoAttachmentFolder() throws Exception { + File zipFile = new File(temporaryFolder.getRoot(), "backup.mnbkn"); + + ZipBackupHelper.writeZipBackup( + new JsonBackup(), new File(temporaryFolder.getRoot(), "missing"), zipFile); + + try (java.io.InputStream in = Files.newInputStream(zipFile.toPath())) { + assertThat(ZipBackupHelper.isZip(in)).isTrue(); + } + } + + @Test + public void isZip_readsOnlyTheMagicFromAStream() throws Exception { + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream(new byte[] {0x50, 0x4B, 3}))) + .isTrue(); + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream("{\"a\":1}".getBytes()))) + .isFalse(); + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream(new byte[] {0x50}))).isFalse(); + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream(new byte[0]))).isFalse(); + } + private static ZipInputStream zip(Map entries) throws Exception { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(bytes)) { From 13aacce37af79aa7a749ff0c531321a46482d6dc Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:37:13 +0200 Subject: [PATCH 27/52] fix(backup): save, restore and import off the main thread and through 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. --- .../mynotes/ui/contract/BackupContract.java | 6 +- .../mynotes/ui/presenter/BackupPresenter.java | 281 ++++++++++++------ .../ui/view/activity/BackupActivity.java | 61 +++- .../ui/view/dialogs/OtherAppImportDialog.java | 127 +++++--- .../presenter/BackupPresenterRestoreTest.java | 133 +++++++++ 5 files changed, 479 insertions(+), 129 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java index ed27d98c..eabdf9c7 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java @@ -47,9 +47,6 @@ interface view extends BaseView { /** Displays the result summary of a Google Keep import. */ void showImportResultOtherApp(GoogleKeepImportResult result); - /** Registers the import result callback to confirm the import. */ - void setupImportCallbackOtherApp(GoogleKeepImportResult result); - /** Processes the user-selected file URI from another app. */ void processSelectedFileOtherApp(Uri fileUri); @@ -81,6 +78,9 @@ interface presenter extends BasePresenter { /** Reads a Google Keep ZIP archive from the given URI. */ void importFromZipOtherApp(Uri fileUri); + /** Drops a scan started by {@link #importFromZipOtherApp} that is no longer wanted. */ + void cancelImportFromZipOtherApp(); + /** Forwards the selected file URI to the view for processing. */ void startProcessSelectedFileOtherApp(Uri fileUri); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java index 4b997a9a..19e26129 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java @@ -2,9 +2,11 @@ import android.net.Uri; import android.util.Log; +import androidx.annotation.Nullable; import com.pasich.mynotes.R; import com.pasich.mynotes.base.presenter.BasePresenter; import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.Tag; import com.pasich.mynotes.ui.contract.BackupContract; @@ -18,13 +20,13 @@ import io.reactivex.Completable; import io.reactivex.Flowable; import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; import io.reactivex.observers.DisposableSingleObserver; -import io.reactivex.schedulers.Schedulers; import java.util.AbstractMap; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; /** Presenter backing the backup and restore screen. */ @@ -32,9 +34,32 @@ public class BackupPresenter extends BasePresenter implements BackupContract.presenter { + private static final String TAG = "BackupPresenter"; + + /** + * Saves and restores outlive the screen that started them. + * + *

A presenter lives exactly as long as its activity, and a rotation in the middle of a + * restore used to dispose it part-way, silently. The work now runs to the end and reports to + * whichever presenter is attached at that moment, or to the next one. Main thread only. + */ + private static final class BackgroundWork { + static boolean restoreRunning; + static boolean saveRunning; + @Nullable static Integer undeliveredRestore; + @Nullable static Boolean undeliveredSave; + @Nullable static BackupPresenter current; + } + @Inject public BackupCacheHelper serviceCache; @Inject GoogleKeepImportService importService; + /** Null in unit tests, where the data manager is a mock; then nothing is transactional. */ + @Inject @Nullable AppDatabase appDatabase; + + /** The scan of another app's export, cancelled when the user closes its dialog. */ + @Nullable private Disposable otherAppScan; + @Inject public BackupPresenter( SchedulerProvider schedulerProvider, @@ -45,12 +70,17 @@ public BackupPresenter( @Override public void viewIsReady() { + BackgroundWork.current = this; getView().initActivity(); getView().initListeners(); + catchUpOnBackgroundWork(); } @Override public void detachView() { + if (BackgroundWork.current == this) { + BackgroundWork.current = null; + } super.detachView(); } @@ -105,80 +135,165 @@ public void saveBackupPresenter(boolean local) { throwable -> Log.e("RxError", "Error: ", throwable))); } - /** Save local backup (3/3) - write appData to public file */ + /** + * Save local backup (3/3) - write appData to public file. + * + *

Off the main thread: the archive packs every attachment and used to be built and copied on + * the main thread from the activity-result callback. Like a restore it is not tied to this + * screen's lifetime; a rotation would otherwise interrupt the write half way through. + */ @Override public void writeFileBackupLocal(Uri mUri) { - getView().createLocalCopyFinish(getDataManager().writeBackupLocalFile(serviceCache, mUri)); + if (BackgroundWork.saveRunning) { + return; + } + BackgroundWork.saveRunning = true; + Disposable ignored = + Single.fromCallable(() -> getDataManager().writeBackupLocalFile(serviceCache, mUri)) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + BackupPresenter::finishSave, + throwable -> { + Log.e(TAG, "Writing the backup failed", throwable); + finishSave(false); + }); } - /** Restore local backup (3/3) - load public file and write data */ + /** + * Restore local backup (3/3) - load public file and write data. + * + *

The file is read off the main thread; it can be an archive of every attachment, which used + * to be read whole into memory on the main thread just to look at its first two bytes. + */ @Override public void readFileBackupLocal(Uri mUri) { + if (BackgroundWork.restoreRunning) { + // One restore at a time; the running one reports to whichever screen is current. + getView().showProcessRestoreDialog(); + return; + } getView().showProcessRestoreDialog(); + BackgroundWork.restoreRunning = true; - getCompositeDisposable() - .add( - Completable.timer(3, java.util.concurrent.TimeUnit.SECONDS) - .observeOn(getSchedulerProvider().ui()) - .subscribe(() -> { - final JsonBackup jsonBackup = - getDataManager().readBackupLocalFile(mUri); - if (jsonBackup.isError()) { - getView().restoreFinish(CloudErrors.BACKUP_DESTROY); - } else { - restoreData(jsonBackup); - } - })); + Disposable ignored = + Completable.timer(3, TimeUnit.SECONDS, getSchedulerProvider().io()) + .andThen( + Single.fromCallable( + () -> getDataManager().readBackupLocalFile(mUri))) + .flatMapCompletable(jsonBackup -> + jsonBackup.isError() + ? Completable.error( + new IllegalStateException( + "The backup could not be read")) + : restoreCompletable(jsonBackup)) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + () -> finishRestore(CloudErrors.OKAY_RESTORE), + throwable -> { + Log.e(TAG, "Restore failed", throwable); + finishRestore(CloudErrors.BACKUP_DESTROY); + }); } /** - * Restore date request rxJava + * Writes restored data, reporting to whichever backup screen is current when it ends. * * @param jsonBackup - data restore */ private void restoreData(JsonBackup jsonBackup) { - if (jsonBackup.getTags() != null && !jsonBackup.getTags().isEmpty()) { - for (int i = 0; i < jsonBackup.getTags().size(); i++) { - Tag tag = jsonBackup.getTags().get(i); - if (tag.getPosition() == 0 && tag.getSystemAction() == 0) { - tag.setPosition(-1); + if (BackgroundWork.restoreRunning) { + return; + } + BackgroundWork.restoreRunning = true; + Disposable ignored = + restoreCompletable(jsonBackup) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + () -> finishRestore(CloudErrors.OKAY_RESTORE), + throwable -> { + Log.e(TAG, "Restore failed", throwable); + finishRestore(CloudErrors.BACKUP_DESTROY); + }); + } + + /** + * Writes notes, tags and trash as one transaction, then the preferences. + * + *

They used to be three transactions merged together and tied to the screen's disposables: a + * rotation part-way through left the notes restored and the tags or the trash not, without a + * word. Now either all of the data lands or none of it, and the preferences only once it has. + * The work is deliberately not added to the screen's disposables. + */ + private Completable restoreCompletable(JsonBackup jsonBackup) { + return Completable.fromAction(() -> { + if (jsonBackup.getTags() != null) { + for (Tag tag : jsonBackup.getTags()) { + if (tag.getPosition() == 0 && tag.getSystemAction() == 0) { + tag.setPosition(-1); + } } } + runInTransaction(() -> { + getDataManager().addNotes(jsonBackup.getNotes()).blockingAwait(); + getDataManager().addTags(jsonBackup.getTags()).blockingAwait(); + getDataManager().addNotes(jsonBackup.getTrashNotesUnified()).blockingAwait(); + }); + getDataManager().setListPreferences(jsonBackup.getPreferences()); + }); + } + + private void runInTransaction(Runnable body) { + if (appDatabase != null) { + appDatabase.runInTransaction(body); + } else { + body.run(); } + } - getCompositeDisposable() - .add( - Completable.fromAction(() -> - getDataManager() - .setListPreferences(jsonBackup.getPreferences())) - .subscribeOn(getSchedulerProvider().io()) - .andThen( - Completable.mergeArray( - getDataManager() - .addNotes( - jsonBackup - .getNotes()), // main notes - getDataManager() - .addTags(jsonBackup.getTags()), // tags - getDataManager() - .addNotes( - jsonBackup - .getTrashNotesUnified()) // trash notes - )) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - () -> { - getView().onRestoreSuccessFlag(); - getView().restoreFinish(CloudErrors.OKAY_RESTORE); - }, - throwable -> { - Log.e( - "BackupRestore", - "Error restore: " + throwable.getMessage(), - throwable); - getView().restoreFinish(CloudErrors.BACKUP_DESTROY); - })); + private static void finishRestore(int infoCode) { + BackgroundWork.restoreRunning = false; + BackupPresenter current = BackgroundWork.current; + if (current != null && current.isViewAttached()) { + current.deliverRestore(infoCode); + } else { + BackgroundWork.undeliveredRestore = infoCode; + } + } + + private void deliverRestore(int infoCode) { + if (infoCode == CloudErrors.OKAY_RESTORE) { + getView().onRestoreSuccessFlag(); + } + getView().restoreFinish(infoCode); + } + + private static void finishSave(boolean saved) { + BackgroundWork.saveRunning = false; + BackupPresenter current = BackgroundWork.current; + if (current != null && current.isViewAttached()) { + current.getView().createLocalCopyFinish(saved); + } else { + BackgroundWork.undeliveredSave = saved; + } + } + + /** Hands a screen recreated meanwhile what finished, or is still running, without it. */ + private void catchUpOnBackgroundWork() { + if (BackgroundWork.restoreRunning) { + getView().showProcessRestoreDialog(); + } else if (BackgroundWork.undeliveredRestore != null) { + int infoCode = BackgroundWork.undeliveredRestore; + BackgroundWork.undeliveredRestore = null; + deliverRestore(infoCode); + } + if (BackgroundWork.undeliveredSave != null) { + boolean saved = BackgroundWork.undeliveredSave; + BackgroundWork.undeliveredSave = null; + getView().createLocalCopyFinish(saved); + } } /** @@ -248,29 +363,27 @@ public void exportAllNotesPresenter() { @Override public void importFromZipOtherApp(Uri fileUri) { - getCompositeDisposable() - .add( - Single.fromCallable(() -> importService.importFromZip(fileUri)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - result -> { - // Після імпорту повідомляємо View про результат - if (getView() != null) { - getView().showImportResultOtherApp(result); - if (!result.hasError()) { - getView().setupImportCallbackOtherApp(result); - } - } - }, - throwable -> { - if (getView() != null) { - getView() - .showImportResultOtherApp( - GoogleKeepImportResult.error( - throwable.getMessage())); - } - })); + cancelImportFromZipOtherApp(); + otherAppScan = + Single.fromCallable(() -> importService.importFromZip(fileUri)) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + result -> runOnView(view -> view.showImportResultOtherApp(result)), + throwable -> + runOnView(view -> + view.showImportResultOtherApp( + GoogleKeepImportResult.error( + throwable.getMessage())))); + getCompositeDisposable().add(otherAppScan); + } + + @Override + public void cancelImportFromZipOtherApp() { + if (otherAppScan != null) { + getCompositeDisposable().remove(otherAppScan); // remove() also disposes it + otherAppScan = null; + } } @Override @@ -349,13 +462,13 @@ public void importDataOtherApp(GoogleKeepImportResult result) { return jsonBackup; }) .subscribeOn(getSchedulerProvider().computation()) - .observeOn(AndroidSchedulers.mainThread()) + .observeOn(getSchedulerProvider().ui()) .subscribe( this::restoreData, throwable -> - getView() - .showErrorsText( - 0, R.string.empty_data_import))); + runOnView(view -> + view.showErrorsText( + 0, R.string.empty_data_import)))); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java index 6abcccbc..8f76050d 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java @@ -82,7 +82,10 @@ /** Activity for creating and restoring app data backups. */ @AndroidEntryPoint public class BackupActivity extends BaseActivity - implements BackupContract.view, AccountSyncFragment.Host, BackupOptionsDialog.Host { + implements BackupContract.view, + AccountSyncFragment.Host, + BackupOptionsDialog.Host, + OtherAppImportDialog.Host { private static final String TAG = "BackupActivity"; @@ -166,7 +169,6 @@ public class BackupActivity extends BaseActivity @Nullable private Uri pendingRestorePick; private static final String STATE_PENDING_RESTORE_PICK = "pendingRestorePick"; - private OtherAppImportDialog importDialog; private Dialog progressDialog; private RoomSyncStore roomSyncStore; private SyncCoordinatorFactory.Result syncSetup; @@ -217,6 +219,7 @@ public void onCreate(Bundle savedInstanceState) { binding.setPresenter((BackupPresenter) presenter); setupTabs(); + resumeOtherAppImport(); if (savedInstanceState != null) { Uri pick = savedInstanceState.getParcelable(STATE_PENDING_RESTORE_PICK); @@ -1187,6 +1190,10 @@ public void dialogRestoreData(boolean local) { @Override public void showProcessRestoreDialog() { + if (progressDialog != null && progressDialog.isShowing()) { + // Already up: a restore picked up after a rotation and a pick being judged both ask. + return; + } MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this, R.style.progressDialogRestore) .setCancelable(false) @@ -1222,19 +1229,57 @@ public void openShareOptionsDialog(java.util.List notes, boolean isDataExp @Override public void processSelectedFileOtherApp(Uri fileUri) { - importDialog = OtherAppImportDialog.newInstance(); - importDialog.show(getSupportFragmentManager(), "import_progress"); - importDialog.updateProgress(true); + if (fileUri == null || importDialog() != null) { + return; + } + OtherAppImportDialog.newInstance(fileUri) + .show(getSupportFragmentManager(), OtherAppImportDialog.TAG); presenter.importFromZipOtherApp(fileUri); } + /** + * Restarts the scan behind an import dialog the framework brought back after a rotation. + * + *

The scan was disposed with the previous instance, so the restored dialog would otherwise + * spin forever. + */ + private void resumeOtherAppImport() { + OtherAppImportDialog dialog = importDialog(); + if (dialog == null || dialog.hasResult()) { + return; + } + Uri file = dialog.getFile(); + if (file == null) { + dialog.dismissAllowingStateLoss(); + return; + } + presenter.importFromZipOtherApp(file); + } + + @Nullable + private OtherAppImportDialog importDialog() { + return getSupportFragmentManager().findFragmentByTag(OtherAppImportDialog.TAG) + instanceof OtherAppImportDialog dialog + ? dialog + : null; + } + @Override public void showImportResultOtherApp(GoogleKeepImportResult result) { - importDialog.showResult(result); + OtherAppImportDialog dialog = importDialog(); + // Null when the user closed the dialog before the scan finished: nothing to show. + if (dialog != null) { + dialog.showResult(result); + } + } + + @Override + public void onOtherAppImportConfirmed(@NonNull GoogleKeepImportResult result) { + presenter.importDataOtherApp(result); } @Override - public void setupImportCallbackOtherApp(GoogleKeepImportResult result) { - importDialog.setCallback(importResult -> presenter.importDataOtherApp(importResult)); + public void onOtherAppImportCancelled() { + presenter.cancelImportFromZipOtherApp(); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/OtherAppImportDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/OtherAppImportDialog.java index 081a0b10..1a838c86 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/OtherAppImportDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/OtherAppImportDialog.java @@ -1,5 +1,7 @@ package com.pasich.mynotes.ui.view.dialogs; +import android.content.DialogInterface; +import android.net.Uri; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -10,22 +12,53 @@ import com.pasich.mynotes.databinding.DialogOtherAppImportBinding; import com.pasich.mynotes.utils.backup.models.googleKeep.GoogleKeepImportResult; +/** + * Shows the scan of another app's export and asks whether to import it. + * + *

The framework recreates this dialog after a rotation, but the scan that feeds it belongs to + * the activity that started it and dies with it, and the scanned result cannot be put in a bundle. + * So the dialog keeps the file it is about in its arguments and the hosting activity, through + * {@link Host}, scans it again; meanwhile the dialog can always be cancelled. + */ public class OtherAppImportDialog extends BottomSheetDialogFragment { + + public static final String TAG = "import_progress"; + private static final String ARG_FILE = "file"; + + /** The screen that runs the scan and the import. */ + public interface Host { + /** The user confirmed importing the scanned data. */ + void onOtherAppImportConfirmed(@NonNull GoogleKeepImportResult result); + + /** The user closed the dialog before importing; any scan still running is not needed. */ + void onOtherAppImportCancelled(); + } + private DialogOtherAppImportBinding binding; - private ImportCallback callback; + + /** The scan's outcome, kept until there is a view to show it in. */ + @Nullable private GoogleKeepImportResult result; public OtherAppImportDialog() {} - public interface ImportCallback { - void onImportConfirmed(GoogleKeepImportResult result); + public static OtherAppImportDialog newInstance(@NonNull Uri file) { + OtherAppImportDialog dialog = new OtherAppImportDialog(); + Bundle args = new Bundle(); + args.putParcelable(ARG_FILE, file); + dialog.setArguments(args); + return dialog; } - public static OtherAppImportDialog newInstance() { - return new OtherAppImportDialog(); + /** The export being scanned, or {@code null} if the dialog was created without one. */ + @Nullable + public Uri getFile() { + Bundle args = getArguments(); + return args == null ? null : args.getParcelable(ARG_FILE); } - public void setCallback(ImportCallback callback) { - this.callback = callback; + /** Whether the scan's outcome has reached this instance. */ + public boolean hasResult() { + return result != null; } @Override @@ -38,7 +71,29 @@ public View onCreateView( @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - setCancelable(false); + // Back and a tap outside cancel too; onCancel tells the host. + setCancelable(true); + binding.cancel.setOnClickListener(v -> { + notifyCancelled(); + dismiss(); + }); + if (result != null) { + bindResult(result); + } else { + updateProgress(true); + } + } + + @Override + public void onCancel(@NonNull DialogInterface dialog) { + super.onCancel(dialog); + notifyCancelled(); + } + + private void notifyCancelled() { + if (getActivity() instanceof Host host) { + host.onOtherAppImportCancelled(); + } } public void updateProgress(boolean scanning) { @@ -48,33 +103,37 @@ public void updateProgress(boolean scanning) { } } - public void showResult(GoogleKeepImportResult result) { + /** Shows the scan's outcome now, or as soon as the view exists. */ + public void showResult(@NonNull GoogleKeepImportResult result) { + this.result = result; if (binding != null) { - updateProgress(false); - - if (result.hasError()) { - binding.errorText.setText(result.getError()); - binding.errorText.setVisibility(View.VISIBLE); - binding.resultContainer.setVisibility(View.GONE); - binding.importButton.setVisibility(View.GONE); - } else { - binding.errorText.setVisibility(View.GONE); - binding.resultContainer.setVisibility(View.VISIBLE); - binding.importButton.setVisibility(View.VISIBLE); - - binding.notesCount.setText(String.valueOf(result.getTotalNotesCount())); - binding.trashedNotesCount.setText(String.valueOf(result.getTrashedNotes().size())); - binding.tagsCount.setText(String.valueOf(result.getLabels().size())); - - binding.importButton.setOnClickListener(v -> { - if (callback != null) { - callback.onImportConfirmed(result); - } - dismiss(); - }); - } - - binding.cancel.setOnClickListener(v -> dismiss()); + bindResult(result); + } + } + + private void bindResult(@NonNull GoogleKeepImportResult result) { + updateProgress(false); + + if (result.hasError()) { + binding.errorText.setText(result.getError()); + binding.errorText.setVisibility(View.VISIBLE); + binding.resultContainer.setVisibility(View.GONE); + binding.importButton.setVisibility(View.GONE); + } else { + binding.errorText.setVisibility(View.GONE); + binding.resultContainer.setVisibility(View.VISIBLE); + binding.importButton.setVisibility(View.VISIBLE); + + binding.notesCount.setText(String.valueOf(result.getTotalNotesCount())); + binding.trashedNotesCount.setText(String.valueOf(result.getTrashedNotes().size())); + binding.tagsCount.setText(String.valueOf(result.getLabels().size())); + + binding.importButton.setOnClickListener(v -> { + if (getActivity() instanceof Host host) { + host.onOtherAppImportConfirmed(result); + } + dismiss(); + }); } } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java new file mode 100644 index 00000000..e12eed12 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java @@ -0,0 +1,133 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gson.Gson; +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.ui.contract.BackupContract; +import com.pasich.mynotes.ui.presenter.BackupPresenter; +import com.pasich.mynotes.utils.backup.models.googleKeep.GoogleKeepImportResult; +import com.pasich.mynotes.utils.backup.models.googleKeep.GoogleKeepNote; +import com.pasich.mynotes.utils.constants.CloudErrors; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +/** A restore is not cut short by the screen that started it going away. */ +public class BackupPresenterRestoreTest extends BasePresenterTest { + + private final TestScheduler io = new TestScheduler(); + private DataManager dataManager; + + @Before + public void setUp() { + dataManager = mock(DataManager.class); + when(dataManager.getNotes()).thenReturn(Flowable.just(new ArrayList<>())); + when(dataManager.getNotesInTrash()).thenReturn(Flowable.just(new ArrayList<>())); + when(dataManager.getTagsUser()).thenReturn(Flowable.just(new ArrayList<>())); + when(dataManager.addNotes(anyList())).thenReturn(Completable.complete()); + when(dataManager.addTags(anyList())).thenReturn(Completable.complete()); + } + + private SchedulerProvider schedulers() { + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler io() { + return io; + } + }; + } + + private BackupPresenter presenter(BackupContract.view view) { + BackupPresenter presenter = + new BackupPresenter(schedulers(), new CompositeDisposable(), dataManager); + presenter.attachView(view); + presenter.viewIsReady(); + return presenter; + } + + private static GoogleKeepImportResult oneKeepNote() { + GoogleKeepNote note = + new Gson() + .fromJson( + "{\"title\":\"t\",\"textContent\":\"body\"}", GoogleKeepNote.class); + return GoogleKeepImportResult.success( + List.of(note), Collections.emptyList(), Collections.emptyList()); + } + + @Test + public void restore_reportsToTheScreenThatIsCurrentWhenItEnds() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view second = mock(BackupContract.view.class); + + BackupPresenter rotatedAway = presenter(first); + rotatedAway.importDataOtherApp(oneKeepNote()); + // A rotation: the old screen goes, a new one comes while the restore is still queued. + rotatedAway.detachView(); + presenter(second); + verify(second).showProcessRestoreDialog(); + + io.triggerActions(); + + verify(dataManager, org.mockito.Mockito.times(2)).addNotes(anyList()); + verify(dataManager).addTags(anyList()); + verify(second).onRestoreSuccessFlag(); + verify(second).restoreFinish(CloudErrors.OKAY_RESTORE); + verify(first, never()).restoreFinish(anyInt()); + } + + @Test + public void restore_finishingWithNoScreen_isReportedToTheNextOne() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view later = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + gone.importDataOtherApp(oneKeepNote()); + gone.detachView(); + io.triggerActions(); + + presenter(later); + + verify(later).restoreFinish(CloudErrors.OKAY_RESTORE); + } + + @Test + public void failedRestore_isReportedNotSwallowed() { + when(dataManager.addTags(anyList())) + .thenReturn(Completable.error(new IllegalStateException("disk full"))); + BackupContract.view view = mock(BackupContract.view.class); + + presenter(view).importDataOtherApp(oneKeepNote()); + io.triggerActions(); + + verify(view).restoreFinish(CloudErrors.BACKUP_DESTROY); + verify(view, never()).onRestoreSuccessFlag(); + verify(dataManager, never()).setListPreferences(any()); + } +} From 96bc400215fc81b86c431f439a990870076ae0ea Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:37:39 +0200 Subject: [PATCH 28/52] fix(sync): stop publishing deletions every device has long seen 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. --- .../data/database/dao/SyncMetadataDao.java | 8 +- .../pasich/mynotes/data/sync/SyncService.java | 66 +++++++++++++++- .../mynotes/data/sync/SyncServiceTest.java | 76 +++++++++++++++++++ 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java index 81fd0d4f..d0e10fab 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java @@ -64,7 +64,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. + * + *

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, " diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java index c602d01d..1ae310f1 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java @@ -34,6 +34,9 @@ public final class SyncService { /** Well under the schema record limit, so a bundle can always still be published. */ static final int MAX_PUBLISHED_SETTLED_IDS = 2_000; + /** How long a deletion keeps travelling in every bundle; see {@link #forPublication}. */ + static final long TOMBSTONE_PUBLICATION_HORIZON_MILLIS = 90L * 24L * 60L * 60L * 1000L; + private final SyncStore store; private final SyncMerger merger; private final Clock clock; @@ -203,11 +206,14 @@ private SyncState syncExclusively(@NonNull SyncBackend backend) { pinConflictVersionQuietly(backend, conflict.getLoser(), synchronizedHashes); } + // What goes to Drive omits deletions every device has long had the chance to see; + // the local apply below still receives all of them. + SyncSnapshot published = forPublication(merged, remote, alternatives.values()); if (needsPublication( - merged, remote, alternatives.keySet(), settledVersionIds, remoteResult)) { + published, remote, alternatives.keySet(), settledVersionIds, remoteResult)) { backend.publish( new SyncPublication( - merged, + published, new java.util.ArrayList<>(alternatives.values()), settledVersionIds, remoteResult)); @@ -300,6 +306,62 @@ static java.util.Set capSettledVersionIds( return trimmed; } + /** + * The merged snapshot as it is published: without the tombstones nobody needs to be told about. + * + *

Tombstones were never dropped, and every one of them went into every bundle. The schema + * caps a bundle at {@link SyncBundleValidator#MAX_RECORD_COUNT} records, live and deleted + * together, so a library that had seen enough deletions over its lifetime failed every publish + * from then on, permanently and on every device. + * + *

The rule, and why it cannot bring a deleted record back: + * + *

    + *
  • A tombstone is published while its deletion is younger than {@link + * #TOMBSTONE_PUBLICATION_HORIZON_MILLIS}. Any device that syncs within that window learns + * of the deletion directly. + *
  • Past it, the tombstone is published only when this sync read a live version of the same + * record from Drive, or an open conflict alternative for it is carried along. A device + * that slept through the horizon and republishes its stale live copy therefore gets the + * tombstone back from the next device that syncs; the merge sees a deletion newer than + * that copy and removes it there too, exactly as it would have without pruning. An edit + * made after the deletion is newer and wins, which is the ordinary rule as well. + *
  • Pruning is publication-only. Every device keeps its tombstone rows in {@code + * sync_metadata} for good — they are what lets it answer a stale copy as above — and + * still applies and merges them locally; only the bundle stops carrying them. + *
+ * + *

The one window left: a fresh device installed while a stale copy is the only thing in + * Drive shows that record until any device holding the tombstone next syncs. + */ + @NonNull + private SyncSnapshot forPublication( + @NonNull SyncSnapshot merged, + @NonNull SyncSnapshot remote, + @NonNull Collection alternatives) { + long horizon = clock.millis() - TOMBSTONE_PUBLICATION_HORIZON_MILLIS; + java.util.Set contested = new java.util.HashSet<>(); + for (SyncRecord alternative : alternatives) { + contested.add(alternative.getType().getWireValue() + ":" + alternative.getId()); + } + java.util.List kept = new java.util.ArrayList<>(); + boolean pruned = false; + for (SyncRecord record : merged.getRecords()) { + if (record.isTombstone() + && record.getDeletedAt().toEpochMilli() < horizon + && !contested.contains( + record.getType().getWireValue() + ":" + record.getId())) { + SyncRecord remoteRecord = remote.find(record.getType(), record.getId()); + if (remoteRecord == null || remoteRecord.isTombstone()) { + pruned = true; + continue; + } + } + kept.add(record); + } + return pruned ? new SyncSnapshot(kept) : merged; + } + /** * Re-points every conflict at the version this sync actually applies. * diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java index 0c1c9aad..3e821967 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java @@ -175,6 +175,60 @@ public void sync_trustsTheStoresDigestCacheInsteadOfReHashingEveryLocalBlob() th assertThat(store.readAttachmentCalls).isEqualTo(0); } + @Test + public void sync_stopsPublishingADeletionOnceItIsPastTheHorizon() { + // Every tombstone used to go into every bundle for good, and the schema caps a bundle's + // records, so enough deletions over a library's life failed every publish permanently. + SyncRecord old = tombstone(NOTE_ID, daysBeforeNow(100)); + SyncRecord recent = tombstone(OTHER_NOTE_ID, daysBeforeNow(10)); + FakeStore store = new FakeStore(snapshot(old, recent)); + FakeBackend backend = new FakeBackend(SyncSnapshot.empty()); + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + assertThat(backend.snapshot.getRecords()).containsExactly(recent); + // The device itself still holds and applies the deletion. + assertThat(store.appliedSnapshot.getRecords()).containsExactly(old, recent); + + // And the omission is not mistaken for news on the next sync. + new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + assertThat(backend.writeSnapshotCalls).isEqualTo(1); + } + + @Test + public void sync_answersAStaleCopyOfAnOldDeletionWithTheTombstone() { + // A device that slept through the horizon never saw the deletion, and once Drive stopped + // carrying the tombstone it republished its live copy. The next device still holding the + // tombstone has to put the deletion back rather than take the copy. + SyncRecord staleCopy = note(daysBeforeNow(101), "Deleted elsewhere long ago"); + SyncRecord deleted = tombstone(NOTE_ID, daysBeforeNow(100)); + FakeStore holder = new FakeStore(snapshot(deleted)); + holder.syncedVersions.put(key(deleted), deleted.getCanonicalPayloadHash()); + FakeBackend backend = new FakeBackend(snapshot(staleCopy)); + + new SyncService(holder, new SyncMerger(), CLOCK).sync(backend); + + assertThat(versions(backend.snapshot)).containsExactly(deleted.getCanonicalPayloadHash()); + assertThat(versions(holder.appliedSnapshot)) + .containsExactly(deleted.getCanonicalPayloadHash()); + + // The stale device now receives the deletion instead of keeping its copy. + FakeStore stale = new FakeStore(snapshot(staleCopy)); + stale.syncedVersions.put(key(staleCopy), staleCopy.getCanonicalPayloadHash()); + new SyncService(stale, new SyncMerger(), CLOCK).sync(backend); + + assertThat(versions(stale.appliedSnapshot)) + .containsExactly(deleted.getCanonicalPayloadHash()); + // Settled everywhere, the tombstone leaves the bundle again and stays out. + assertThat(backend.snapshot.getRecords()).isEmpty(); + int publishes = backend.writeSnapshotCalls; + new SyncService(holder, new SyncMerger(), CLOCK).sync(backend); + assertThat(backend.writeSnapshotCalls).isEqualTo(publishes); + assertThat(versions(holder.appliedSnapshot)) + .containsExactly(deleted.getCanonicalPayloadHash()); + } + @Test public void sync_repairsCorruptLocalAttachmentFromRemote() throws Exception { SyncRecord local = note(TEN, "Local with corrupt attachment"); @@ -459,6 +513,28 @@ public void sync_namesTheNoteWhenABlobNoEndpointHoldsWasPublishedFromMemory() th assertThat(backend.writeSnapshotCalls).isEqualTo(0); } + private static final String OTHER_NOTE_ID = "550e8400-e29b-41d4-a716-446655440001"; + + private static Instant daysBeforeNow(long days) { + return TWENTY.minus(java.time.Duration.ofDays(days)); + } + + private static SyncRecord tombstone(String id, Instant deletedAt) { + return SyncRecord.tombstone(SyncRecord.Type.NOTE, id, deletedAt, deletedAt); + } + + private static List versions(SyncSnapshot snapshot) { + List versions = new ArrayList<>(); + for (SyncRecord record : snapshot.getRecords()) { + versions.add(record.getCanonicalPayloadHash()); + } + return versions; + } + + private static String key(SyncRecord record) { + return record.getType() + ":" + record.getId(); + } + private static SyncSnapshot snapshot(SyncRecord... records) { return new SyncSnapshot(Arrays.asList(records)); } From 6fe1774e4545e58507bc670ee03c4908de502061 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:37:46 +0200 Subject: [PATCH 29/52] fix(editor): hand the editor's last edits over before closing, switching or recreating Back decided whether a new note was empty from what the presenter held, without asking the editor for edits it had not passed on yet: a note typed into just before Back (within the simple editor's 250 ms commit or before the extended page's next send) was deleted, and the later onStop flush updated a deleted row. Back and Up now flush the editor first (the simple editor commits its pending body; the extended page is asked to flush and answers through onFlushFinished, with a timeout) and only then decide. Switching editors started the other editor, which reads the note from the database, before this one's last edits were written. It now flushes the editor and waits for the presenter's write (flushPending with a callback) before opening the other editor. On a rotation the extended page hands over its last document after the old screen saved its state, often after the new screen read the note. The old screen keeps that document in a retained ViewModel (RetainedNoteDraft), and the new screen waits for the old page to finish before it shows the note, so it never shows, or later saves over, an older document. --- .../extendedEditor/NoteEditorView.java | 34 ++++++- .../utils/EditorJSInterface.java | 52 ++++++++++ .../mynotes/ui/presenter/NotePresenter.java | 34 ++++++- .../noteEditor/BaseNoteEditorActivity.java | 48 +++++++++- .../activity/noteEditor/NoteActivity.java | 8 ++ .../NoteExtendedEditorActivity.java | 61 +++++++++++- .../utils/editor/RetainedNoteDraft.java | 94 +++++++++++++++++++ .../utils/EditorJSInterfaceFinishTest.java | 59 ++++++++++++ .../presenter/NotePresenterFlushTest.java | 60 ++++++++++++ .../utils/editor/RetainedNoteDraftTest.java | 83 ++++++++++++++++ 10 files changed, 520 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java index 2c039884..5ad1f858 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java @@ -490,6 +490,18 @@ public void requestFlush() { if (editorIsReady && editorInterface != null) editorInterface.requestFlush(); } + /** + * Asks the editor for its document right away and runs {@code then} once it has been handed + * over (or the page did not answer in time); at once when there is no page to ask. + */ + public void flush(@NonNull Runnable then) { + if (!editorIsReady || editorInterface == null || releasing) { + then.run(); + return; + } + editorInterface.flush(then); + } + /** * Handles result from WebView file chooser, including validation (size limit, free space), * before passing the file to JS. @@ -613,13 +625,29 @@ public void release() { * at once lost the last edit and made a flush still on its way reach a destroyed WebView. */ public void release(boolean keepHistory) { - if (releasing) return; + release(keepHistory, null); + } + + /** + * Like {@link #release(boolean)}; {@code onFinished} runs once the page has handed over + * everything and the WebView is gone. + */ + public void release(boolean keepHistory, @Nullable Runnable onFinished) { + if (releasing) { + if (onFinished != null) onFinished.run(); + return; + } releasing = true; if (handler != null) handler.removeCallbacksAndMessages(null); + Runnable finish = + () -> { + destroyWebView(); + if (onFinished != null) onFinished.run(); + }; if (editorIsReady && editorInterface != null && webView != null) { - editorInterface.finishPage(keepHistory, this::destroyWebView); + editorInterface.finishPage(keepHistory, finish); } else { - destroyWebView(); + finish.run(); } } diff --git a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java index 14d0f935..2cead47d 100644 --- a/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java +++ b/app/src/main/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterface.java @@ -23,8 +23,10 @@ import com.pasich.mynotes.extendedEditor.models.SettingsEditorJsBridge; import java.io.File; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.json.JSONArray; @@ -65,6 +67,9 @@ public class EditorJSInterface { private volatile boolean released = false; // Runs once the page has finished (onPageFinished) or the wait timed out. @Nullable private volatile Runnable pendingFinish; + // Flushes waiting for the page's answer, by id: what to run and its timeout. Main thread only. + private final TreeMap pendingFlushes = new TreeMap<>(); + private int lastFlushId = 0; public EditorJSInterface( EditorListener listener, @@ -241,6 +246,50 @@ public void requestFlush() { evaluate("window.flushContent && flushContent();"); } + /** + * Asks the editor to send its document now and runs {@code then} on the main thread once it has + * (after the listener's onContentFlushed, when the document had changed), or after {@link + * #FINISH_TIMEOUT_MS} when the page does not answer. Called on the main thread. + */ + public void flush(@NonNull Runnable then) { + if (webView == null || released) { + then.run(); + return; + } + int id = ++lastFlushId; + Runnable timeout = () -> finishFlushes(id); + pendingFlushes.put(id, new Runnable[] {then, timeout}); + mainHandler.postDelayed(timeout, FINISH_TIMEOUT_MS); + evaluate( + "(window.flushContent ? flushContent() : Promise.resolve()).finally(() => " + + nameInterface + + ".onFlushFinished(" + + id + + "));"); + } + + /** Answer to {@link #flush}: everything up to flush {@code id} was sent. */ + @SuppressWarnings("unused") + @JavascriptInterface + public void onFlushFinished(int id) { + // Posted after the onContentFlushed the page sent first, so it runs after it. + mainHandler.post(() -> finishFlushes(id)); + } + + /** Runs the flushes up to {@code id} that are still waiting. Main thread. */ + private void finishFlushes(int id) { + List ready = new ArrayList<>(); + Iterator> it = pendingFlushes.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (entry.getKey() > id) break; + mainHandler.removeCallbacks(entry.getValue()[1]); + ready.add(entry.getValue()[0]); + it.remove(); + } + for (Runnable then : ready) then.run(); + } + /** * The screen is going away: asks the page for its last edit (and, with {@code keepHistory}, its * undo history, through onHistoryExported) and runs {@code then} on the main thread once the @@ -496,6 +545,9 @@ public void release() { if (finish != null) mainHandler.removeCallbacks(finish); pendingFinish = null; uploads.shutdownNow(); + // Nothing more will come from the page: nobody waits for it any longer. + if (Looper.myLooper() == Looper.getMainLooper()) finishFlushes(Integer.MAX_VALUE); + else mainHandler.post(() -> finishFlushes(Integer.MAX_VALUE)); } private PickedFile takePickedFile(String name, long size) { diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java index d4321b79..6714e978 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java @@ -3,6 +3,7 @@ import android.content.Intent; import android.graphics.Typeface; import android.util.Log; +import androidx.annotation.Nullable; import com.pasich.mynotes.base.presenter.BasePresenter; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.data.model.Note; @@ -18,7 +19,9 @@ import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; import io.reactivex.subjects.PublishSubject; +import java.util.ArrayList; import java.util.Date; +import java.util.List; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -46,6 +49,9 @@ public class NotePresenter extends BasePresenter // A save started by flushPending(). Kept out of the composite on purpose: the screen is often // being destroyed right after onStop, and disposing the composite must not cancel this write. private Disposable flushSave; + // Waiting for the next flushPending write to finish. A flush that replaces one still in flight + // takes over its waiters, so none is left behind. + private final List flushWaiters = new ArrayList<>(); @Inject public NotePresenter( @@ -181,9 +187,22 @@ public void onError(Throwable error) { */ @Override public void flushPending() { - if (targetNote == null) return; - syncDocumentDerivedFields(); - if (!hasMeaningfulContent(targetNote) || !isContentChanged()) return; + flushPending(null); + } + + /** + * Like {@link #flushPending()}, then runs {@code onDone} on the UI thread once the note is in + * the database (or the write failed), at once when there is nothing to write. Another screen + * that reads the note from the database, such as the other editor, is opened from {@code + * onDone} so it never reads it before the last edits were written. + */ + public void flushPending(@Nullable Runnable onDone) { + if (onDone != null) flushWaiters.add(onDone); + if (targetNote != null) syncDocumentDerivedFields(); + if (targetNote == null || !hasMeaningfulContent(targetNote) || !isContentChanged()) { + runFlushWaiters(); + return; + } Note note = targetNote; note.setDate(new Date().getTime()); @@ -199,13 +218,22 @@ public void flushPending() { () -> { savedNote.copyFrom(note); callback.onSuccess(); + runFlushWaiters(); }, error -> { Log.e(TAG, "flushPending() failed", error); callback.onError(error); + runFlushWaiters(); }); } + private void runFlushWaiters() { + if (flushWaiters.isEmpty()) return; + List waiting = new ArrayList<>(flushWaiters); + flushWaiters.clear(); + for (Runnable waiter : waiting) waiter.run(); + } + @Override public boolean hasUnsavedChanges() { if (targetNote == null) return false; diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java index dfbb6af0..3562e22d 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/BaseNoteEditorActivity.java @@ -61,6 +61,10 @@ public abstract class BaseNoteEditorActivity extends Base @Nullable private Runnable editHistoryObserver; protected T binding; protected long idNote; + // Back was pressed and the editor's last edits are on their way to the presenter. + private boolean closeRequested = false; + // The other editor opens once this one's last edits are written. + private boolean switchingEditor = false; /** Version history; a restored version is read back into this editor. */ private final ActivityResultLauncher versionHistoryLauncher = @@ -164,7 +168,7 @@ public void onCreate(Bundle savedInstanceState) { new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - notePresenter.closeActivity(); + requestClose(); } }); } @@ -386,7 +390,7 @@ public void settingsActionBar() { @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == android.R.id.home) { - notePresenter.closeActivity(); + requestClose(); } if (item.getItemId() == R.id.moreBut) { @@ -481,8 +485,48 @@ public void openCopyNote(long idNote) { notePresenter.copyNoteRequest(); } + /** + * Hands whatever the editor has not passed to the presenter yet over to it, then runs {@code + * then} on the main thread. The simple editor copies its body out of the field once typing + * pauses and the extended editor's page sends its document on its own schedule, so the + * presenter alone can be a little behind what is on screen. + */ + protected void flushEditor(@NonNull Runnable then) { + then.run(); + } + + /** + * Back or Up: the presenter decides whether a new note is empty and deleted, or saved, only + * once it holds every edit; deciding on what it held before deleted a note typed into just + * before Back. + */ + private void requestClose() { + if (closeRequested || notePresenter == null) return; + closeRequested = true; + flushEditor(() -> { + closeRequested = false; + if (isDestroyed() || isFinishing()) return; + notePresenter.closeActivity(); + }); + } + + /** + * Opens the note in the other editor. The other editor reads the note from the database, so + * this one's last edits are handed to the presenter and written first. + */ @Override public void changeEditor(long idNote) { + if (switchingEditor || notePresenter == null) return; + switchingEditor = true; + flushEditor(() -> + ((NotePresenter) notePresenter) + .flushPending(() -> { + if (isDestroyed() || isFinishing()) return; + openOtherEditor(idNote); + })); + } + + private void openOtherEditor(long idNote) { Intent intent = new Intent( this, diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java index b8dcb50c..95deadb7 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java @@ -689,6 +689,14 @@ private void saveViewState() { scrollable > 0 ? (float) scrollY / scrollable : 0f)); } + @Override + protected void flushEditor(@NonNull Runnable then) { + if (binding != null && notePresenter != null && notePresenter.hasNote()) { + commitPendingValueEdit(); + } + then.run(); + } + @Override public void onStop() { super.onStop(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java index 9162222b..66688f6a 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java @@ -43,6 +43,7 @@ import com.pasich.mynotes.utils.editor.NoteViewStateStore; import com.pasich.mynotes.utils.editor.PositionRestorer; import com.pasich.mynotes.utils.editor.RetainedEditHistory; +import com.pasich.mynotes.utils.editor.RetainedNoteDraft; import com.pasich.mynotes.utils.navigation.NoteExtras; import dagger.hilt.android.AndroidEntryPoint; import jakarta.inject.Inject; @@ -93,6 +94,7 @@ public class NoteExtendedEditorActivity private String draftTitle; private String draftJson; private RetainedEditHistory retainedEditHistory; + private RetainedNoteDraft retainedDraft; @Override public void onCreate(Bundle savedInstanceState) { @@ -104,6 +106,7 @@ public void onCreate(Bundle savedInstanceState) { } super.onCreate(savedInstanceState); retainedEditHistory = new ViewModelProvider(this).get(RetainedEditHistory.class); + retainedDraft = new ViewModelProvider(this).get(RetainedNoteDraft.class); if (savedInstanceState != null && binding != null) { // The page of the screen this one replaced hands its undo history over as it finishes. binding.noteEditor.setHandedOverHistory(retainedEditHistory::take); @@ -164,6 +167,8 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { if (json == null || json.length() <= MAX_DRAFT_CHARS) { outState.putString(STATE_DRAFT_TITLE, note.getTitle()); outState.putString(STATE_DRAFT_JSON, json); + } else { + retainedDraft.put(notePresenter.getIdKey(), note.getTitle(), json); } } } @@ -247,12 +252,16 @@ public void onEditorReady() { @Override public void onContentChanged(String json) { - runOnUiThread(() -> processTextChange(json)); + runOnUiThread(() -> { + keepForRecreatedScreen(json); + processTextChange(json); + }); } @Override public void onContentFlushed(String json) { runOnUiThread(() -> { + keepForRecreatedScreen(json); processTextChange(json); notePresenter.flushPending(); }); @@ -467,6 +476,26 @@ private void processTitleChange(String title) { notePresenter.extendedNoteChange(title, null); } + /** + * While the screen is being recreated (a rotation), the page's last document arrives after this + * screen saved its state, often after the next screen started reading the note: it is kept for + * that screen, which waits for this page to finish before it shows the note. + */ + private void keepForRecreatedScreen(String json) { + if (json != null && retainedDraft != null && isChangingConfigurations()) { + retainedDraft.put(notePresenter.getIdKey(), null, json); + } + } + + @Override + protected void flushEditor(@NonNull Runnable then) { + if (binding == null) { + then.run(); + return; + } + binding.noteEditor.flush(then); + } + /** Process text changes with enhanced features */ private void processTextChange(String jsonData) { notePresenter.extendedNoteChange(null, jsonData); @@ -540,7 +569,13 @@ public void onDestroy() { binding.titleToolbarTagCollapsed.setOnClickListener(null); // The WebView goes once the page has handed over its last edit and, when the screen // is only being recreated, its undo history. - binding.noteEditor.release(isChangingConfigurations()); + if (isChangingConfigurations() && retainedDraft != null) { + RetainedNoteDraft draft = retainedDraft; + draft.previousPageFinishing(); + binding.noteEditor.release(true, draft::previousPageFinished); + } else { + binding.noteEditor.release(false); + } if (binding.noteEditor.getParent() instanceof ViewGroup) { ((ViewGroup) binding.noteEditor.getParent()).removeView(binding.noteEditor); } @@ -574,10 +609,24 @@ public void loadingNote(Note note) { } } - String pendingTitle = draftTitle; - String pendingJson = draftJson; + String bundledTitle = draftTitle; + String bundledJson = draftJson; draftTitle = null; draftJson = null; + // After a recreation, the previous page may still be handing over its last document. + retainedDraft.whenPreviousPageFinished(() -> { + if (binding == null || isDestroyed()) return; + showNote(note, bundledTitle, bundledJson); + }); + } + + private void showNote(Note note, String pendingTitle, String pendingJson) { + RetainedNoteDraft.Draft kept = retainedDraft.take(note.getId()); + if (kept != null) { + // Newer than the saved state: it arrived as, or after, the state was saved. + if (kept.title != null) pendingTitle = kept.title; + if (kept.body != null) pendingJson = kept.body; + } if (pendingTitle == null && pendingJson == null) { binding.noteEditor.load(note); return; @@ -592,7 +641,9 @@ public void loadingNote(Note note) { if (pendingTitle != null) shown.setTitle(pendingTitle); if (pendingJson != null) shown.setValueJson(pendingJson); binding.noteEditor.load(shown); - binding.getRoot().post(() -> notePresenter.extendedNoteChange(pendingTitle, pendingJson)); + String title = pendingTitle; + String json = pendingJson; + binding.getRoot().post(() -> notePresenter.extendedNoteChange(title, json)); } @Override diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java new file mode 100644 index 00000000..32630975 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java @@ -0,0 +1,94 @@ +package com.pasich.mynotes.utils.editor; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModel; +import java.util.ArrayList; +import java.util.List; + +/** + * Carries a note's latest unsaved edits across a recreation of the editor screen, such as a + * rotation, in memory. + * + *

The saved-state bundle has a size limit, so a long draft cannot go there; and the extended + * editor's page hands over its last document asynchronously, after the old screen has saved its + * state and often after the new screen has started. The old screen keeps that document here, and + * the new one waits for the old page to finish ({@link #whenPreviousPageFinished}) before it shows + * the note, so it never shows, or later saves over, an older document than was typed. + * + *

Used on the main thread only. + */ +public class RetainedNoteDraft extends ViewModel { + + /** A note's title and body (plain text or the extended editor's JSON); null when unchanged. */ + public static final class Draft { + @Nullable public final String title; + @Nullable public final String body; + + Draft(@Nullable String title, @Nullable String body) { + this.title = title; + this.body = body; + } + } + + private long noteId; + @Nullable private String title; + @Nullable private String body; + // Pages of replaced screens still handing over their last edit. + private int pagesFinishing; + private final List afterPreviousPage = new ArrayList<>(); + + /** Keeps the latest draft of note {@code noteId}; a null part keeps what was kept before. */ + @MainThread + public void put(long noteId, @Nullable String title, @Nullable String body) { + if (noteId != this.noteId) { + this.title = null; + this.body = null; + } + this.noteId = noteId; + if (title != null) this.title = title; + if (body != null) this.body = body; + } + + /** The draft kept for {@code noteId}, which is then forgotten; null when there is none. */ + @Nullable + @MainThread + public Draft take(long noteId) { + boolean ours = noteId > 0 && noteId == this.noteId && (title != null || body != null); + Draft draft = ours ? new Draft(title, body) : null; + title = null; + body = null; + return draft; + } + + /** The screen is being recreated and its page is still handing over its last edit. */ + @MainThread + public void previousPageFinishing() { + pagesFinishing++; + } + + /** A finishing page has handed over everything it had, or will never answer. */ + @MainThread + public void previousPageFinished() { + if (pagesFinishing > 0) pagesFinishing--; + if (pagesFinishing > 0) return; + List waiting = new ArrayList<>(afterPreviousPage); + afterPreviousPage.clear(); + for (Runnable action : waiting) action.run(); + } + + /** Runs {@code action} once the page of the screen this one replaced has finished. */ + @MainThread + public void whenPreviousPageFinished(@NonNull Runnable action) { + if (pagesFinishing > 0) afterPreviousPage.add(action); + else action.run(); + } + + @Override + protected void onCleared() { + afterPreviousPage.clear(); + title = null; + body = null; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java index c3b3fe6e..39233230 100644 --- a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java @@ -110,6 +110,65 @@ public void aFlushPostedBeforeTheWebViewWent_isDroppedWhenItRuns() throws Except verify(webView, never()).evaluateJavascript(any(), any()); } + @Test + public void aFlushWaitsForThePageAndRunsAfterTheFlushedDocument() { + StringBuilder order = new StringBuilder(); + EditorJSInterface.EditorListener listener = mock(EditorJSInterface.EditorListener.class); + org.mockito.Mockito.doAnswer( + invocation -> { + // As the activity does: handled on the main thread, posted from the + // bridge. + new android.os.Handler(Looper.getMainLooper()) + .post(() -> order.append("document;")); + return null; + }) + .when(listener) + .onContentFlushed(any()); + EditorJSInterface page = + new EditorJSInterface( + listener, + webView, + RuntimeEnvironment.getApplication(), + new SettingsEditorJsBridge(false)); + + page.flush(() -> order.append("close;")); + verify(webView).evaluateJavascript(contains("onFlushFinished(1)"), any()); + idleFor(10); + assertThat(order.toString()).isEmpty(); + + // The page sends the document, then reports the flush finished. + page.onContentFlushed("[]"); + page.onFlushFinished(1); + idleFor(0); + assertThat(order.toString()).isEqualTo("document;close;"); + + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS * 2); + assertThat(order.toString()).isEqualTo("document;close;"); + } + + @Test + public void aFlushThePageNeverAnswers_runsAfterTheTimeout() { + AtomicInteger closed = new AtomicInteger(); + bridge.flush(closed::incrementAndGet); + + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS - 10); + assertThat(closed.get()).isEqualTo(0); + idleFor(20); + assertThat(closed.get()).isEqualTo(1); + } + + @Test + public void aFlushStillWaiting_runsWhenTheWebViewGoes() { + AtomicInteger closed = new AtomicInteger(); + bridge.flush(closed::incrementAndGet); + + bridge.release(); + assertThat(closed.get()).isEqualTo(1); + + bridge.flush(closed::incrementAndGet); + assertThat(closed.get()).isEqualTo(2); + } + @Test public void finishingAnAlreadyDestroyedPage_runsAtOnce() { bridge.release(); diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java index 5e34ec40..2a9a9025 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java @@ -201,4 +201,64 @@ public void emptiedExtendedNoteNeitherSavesNorCleansAttachments() { verify(dataManager, never()).updateNote(any()); verify(view, never()).runAttachmentsCleanup(any()); } + + @Test + public void theOtherEditorOpensOnlyOnceTheLastEditIsWritten() { + List events = new ArrayList<>(); + presenter.simpleNoteChange(null, "typed just now", false); + + presenter.flushPending(() -> events.add("done after " + writtenValues)); + assertThat(events).isEmpty(); + + ioScheduler.triggerActions(); + assertThat(events).containsExactly("done after [typed just now]"); + } + + @Test + public void withNothingToWriteTheFlushIsDoneAtOnce() { + List events = new ArrayList<>(); + + presenter.flushPending(() -> events.add("done")); + + assertThat(events).containsExactly("done"); + verify(dataManager, never()).updateNote(any()); + } + + @Test + public void aFlushThatReplacesOneInFlightKeepsItsWaiter() { + List events = new ArrayList<>(); + presenter.simpleNoteChange(null, "first", false); + presenter.flushPending(() -> events.add("switch")); + + // The screen stops before the first write ran: onStop flushes again. + presenter.simpleNoteChange(null, "second", false); + presenter.flushPending(); + ioScheduler.triggerActions(); + + assertThat(events).containsExactly("switch"); + assertThat(writtenValues).contains("second"); + } + + /** + * Back on a new note just typed into: once the editor has handed over its document, the note is + * saved and the screen closes; it is not taken for an empty note and deleted. + */ + @Test + public void closingANewNoteAfterTheFlushedDocumentArrivesSavesIt() { + presenter.setNewNoteKey(true); + presenter.setExtendedEditor(true); + note.setValue(""); + note.setTitle(""); + presenter.setNote(note); + when(dataManager.deleteNote(any(Note.class))).thenReturn(Completable.complete()); + + // What the page's flush delivers before the close is decided. + presenter.extendedNoteChange(null, DOC_HELLO); + presenter.flushPending(); + presenter.closeActivity(); + ioScheduler.triggerActions(); + + verify(dataManager, never()).deleteNote(any(Note.class)); + verify(view).closeNoteActivity(); + } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java new file mode 100644 index 00000000..0f9f0deb --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java @@ -0,0 +1,83 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class RetainedNoteDraftTest { + + @Test + public void theDraftIsTakenOnceForItsNote() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "title", "body"); + + RetainedNoteDraft.Draft draft = retained.take(7); + assertThat(draft.title).isEqualTo("title"); + assertThat(draft.body).isEqualTo("body"); + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aLaterDocumentReplacesTheBodyAndKeepsTheTitle() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "title", "[1]"); + retained.put(7, null, "[2]"); + + RetainedNoteDraft.Draft draft = retained.take(7); + assertThat(draft.title).isEqualTo("title"); + assertThat(draft.body).isEqualTo("[2]"); + } + + @Test + public void anotherNote_getsNothing() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, null, "[1]"); + + assertThat(retained.take(8)).isNull(); + assertThat(retained.take(7)).isNull(); + } + + /** + * After a rotation the new screen shows the note only once the old page has handed over its + * last document, which arrives after the new screen has started. + */ + @Test + public void theNewScreenWaitsForTheOldPage() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + List shown = new ArrayList<>(); + retained.previousPageFinishing(); + + retained.whenPreviousPageFinished(() -> shown.add(retained.take(7).body)); + assertThat(shown).isEmpty(); + + retained.put(7, null, "[last]"); + retained.previousPageFinished(); + assertThat(shown).containsExactly("[last]"); + } + + @Test + public void withNoPageFinishingTheNoteIsShownAtOnce() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + List shown = new ArrayList<>(); + + retained.whenPreviousPageFinished(() -> shown.add("now")); + + assertThat(shown).containsExactly("now"); + } + + @Test + public void twoQuickRecreationsWaitForBothPages() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + List shown = new ArrayList<>(); + retained.previousPageFinishing(); + retained.previousPageFinishing(); + retained.whenPreviousPageFinished(() -> shown.add("now")); + + retained.previousPageFinished(); + assertThat(shown).isEmpty(); + retained.previousPageFinished(); + assertThat(shown).containsExactly("now"); + } +} From cc17f1ffb07a73848e895c9deeb4b12af279a096 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:38:20 +0200 Subject: [PATCH 30/52] perf(tasks): rebind only changed rows and write a reorder in one go Every database change rebound the whole task list, and a drag wrote each task separately, so N writes produced N reloads and N full rebinds. The adapter now diffs against snapshots of what it shows, a reorder writes only the tasks that moved in one transaction (DataManager.updateTasks), and switching category replaces only the list subscription instead of clearing every subscription, which also cancelled writes still in flight. --- .../pasich/mynotes/data/AppDataManager.java | 5 + .../mynotes/data/database/AppDbHelper.java | 9 ++ .../data/database/helpers/DbTasksHelper.java | 3 + .../mynotes/ui/presenter/TasksPresenter.java | 61 +++++---- .../utils/adapters/tasks/TasksAdapter.java | 94 +++++++++++++- .../mynotes/presenter/TasksPresenterTest.java | 118 ++++++++++++++++++ .../utils/adapters/TasksAdapterDiffTest.java | 96 ++++++++++++++ 7 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java diff --git a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java index 7bb65fc2..7d40cba0 100644 --- a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java +++ b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java @@ -402,6 +402,11 @@ public Completable updateTask(Task task) { return dbHelper.updateTask(task); } + @Override + public Completable updateTasks(List tasks) { + return dbHelper.updateTasks(tasks); + } + @Override public Completable deleteTask(Task task) { return dbHelper.deleteTask(task); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java index 84f6f604..0abbc5d8 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java @@ -306,6 +306,15 @@ public Completable updateTask(Task task) { return Completable.fromAction(() -> syncMutationCoordinator.updateTask(task)); } + @Override + public Completable updateTasks(List 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)); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbTasksHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbTasksHelper.java index 72b413e0..4277f5b4 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbTasksHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbTasksHelper.java @@ -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 tasks); + Completable deleteTask(Task task); Completable toggleTask(int taskId, boolean isDone); diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java index 98f1063f..39be7a86 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java @@ -13,6 +13,8 @@ import dagger.hilt.android.scopes.ActivityScoped; import io.reactivex.Flowable; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.SerialDisposable; +import java.util.ArrayList; import java.util.List; import javax.inject.Inject; @@ -24,6 +26,7 @@ public class TasksPresenter extends BasePresenter private static final String TAG = "TasksPresenter"; private int selectedCategoryId = 0; private final Context appContext; + private SerialDisposable tasksSubscription; @Inject public TasksPresenter( @@ -58,8 +61,11 @@ private void loadCategories() { } private void loadTasks() { - getCompositeDisposable().clear(); - loadCategories(); + // Replaces only the previous list subscription; writes still running are left alone. + if (tasksSubscription == null) { + tasksSubscription = new SerialDisposable(); + getCompositeDisposable().add(tasksSubscription); + } Flowable> activeStream = selectedCategoryId == 0 @@ -68,25 +74,24 @@ private void loadTasks() { Flowable> completedStream = getDataManager().getCompletedTasks(); - getCompositeDisposable() - .add( - Flowable.combineLatest( - activeStream, - completedStream, - (active, completed) -> new Object[] {active, completed}) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - pair -> { - if (isViewAttached()) { - @SuppressWarnings("unchecked") - List active = (List) pair[0]; - @SuppressWarnings("unchecked") - List completed = (List) pair[1]; - getView().renderTasks(active, completed); - } - }, - err -> Log.e(TAG, "loadTasks", err))); + tasksSubscription.set( + Flowable.combineLatest( + activeStream, + completedStream, + (active, completed) -> new Object[] {active, completed}) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + pair -> { + if (isViewAttached()) { + @SuppressWarnings("unchecked") + List active = (List) pair[0]; + @SuppressWarnings("unchecked") + List completed = (List) pair[1]; + getView().renderTasks(active, completed); + } + }, + err -> Log.e(TAG, "loadTasks", err))); } @Override @@ -180,13 +185,21 @@ public void editTask(Task task, String newTitle, String newDescription) { @Override public void updatePositions(List tasks) { + // Only the tasks that moved are written, in one transaction: one reload of the list + // instead of one per task. + List moved = new ArrayList<>(); for (int i = 0; i < tasks.size(); i++) { - tasks.get(i).setPosition(i); + Task task = tasks.get(i); + if (task.getPosition() != i) { + task.setPosition(i); + moved.add(task); + } } + if (moved.isEmpty()) return; getCompositeDisposable() .add( - io.reactivex.Observable.fromIterable(tasks) - .flatMapCompletable(t -> getDataManager().updateTask(t)) + getDataManager() + .updateTasks(moved) .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) .subscribe(() -> {}, err -> Log.e(TAG, "updatePositions", err))); diff --git a/app/src/main/java/com/pasich/mynotes/utils/adapters/tasks/TasksAdapter.java b/app/src/main/java/com/pasich/mynotes/utils/adapters/tasks/TasksAdapter.java index 53bf3aaa..e8cf5c08 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/adapters/tasks/TasksAdapter.java +++ b/app/src/main/java/com/pasich/mynotes/utils/adapters/tasks/TasksAdapter.java @@ -12,6 +12,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Task; @@ -21,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; public class TasksAdapter extends RecyclerView.Adapter { @@ -109,14 +111,104 @@ private void rebuildDisplayList() { list.addAll(completedTasks); } } + // Snapshots of what is shown: the presenter edits Task objects in place, so the bound + // objects themselves cannot tell what the rows show. + List keys = new ArrayList<>(list.size()); + for (Object item : list) keys.add(item instanceof Task ? snapshot((Task) item) : item); + + List oldKeys = displayKeys; + boolean headerChanged = + shownCompletedCount != completedTasks.size() || shownExpanded != completedExpanded; displayList = list; - notifyDataSetChanged(); + displayKeys = keys; + shownCompletedCount = completedTasks.size(); + shownExpanded = completedExpanded; + // Only the rows that changed are rebound; every database change used to rebind them all. + DiffUtil.calculateDiff(new DisplayDiff(oldKeys, keys, headerChanged)) + .dispatchUpdatesTo(this); + } + + private List displayKeys = new ArrayList<>(); + private int shownCompletedCount = -1; + private boolean shownExpanded; + + private static Task snapshot(Task t) { + Task copy = new Task(); + copy.setId(t.getId()); + copy.setTitle(t.getTitle()); + copy.setDescription(t.getDescription()); + copy.setDone(t.isDone()); + copy.setCategoryId(t.getCategoryId()); + copy.setCreatedAt(t.getCreatedAt()); + copy.setPosition(t.getPosition()); + copy.setReminderTime(t.getReminderTime()); + copy.setReminderIntervalMinutes(t.getReminderIntervalMinutes()); + return copy; + } + + /** Rows are the same task by id; a task row changes when any of its fields changes. */ + static final class DisplayDiff extends DiffUtil.Callback { + private final List oldList; + private final List newList; + private final boolean headerChanged; + + DisplayDiff(List oldList, List newList, boolean headerChanged) { + this.oldList = oldList; + this.newList = newList; + this.headerChanged = headerChanged; + } + + @Override + public int getOldListSize() { + return oldList.size(); + } + + @Override + public int getNewListSize() { + return newList.size(); + } + + @Override + public boolean areItemsTheSame(int oldPos, int newPos) { + Object a = oldList.get(oldPos); + Object b = newList.get(newPos); + if (a instanceof Task && b instanceof Task) { + return ((Task) a).getId() == ((Task) b).getId() + && ((Task) a).isDone() == ((Task) b).isDone(); + } + return a.equals(b); + } + + @Override + public boolean areContentsTheSame(int oldPos, int newPos) { + Object a = oldList.get(oldPos); + Object b = newList.get(newPos); + if (a instanceof Task && b instanceof Task) return sameContent((Task) a, (Task) b); + if (SENTINEL_HEADER.equals(a)) return !headerChanged; + return true; + } + } + + /** + * Compares every stored field: an unchanged row keeps the Task it was bound with, and its click + * handlers hand that object back to be written, so it must not be stale in any field. + */ + static boolean sameContent(Task a, Task b) { + return a.isDone() == b.isDone() + && a.getPosition() == b.getPosition() + && a.getCreatedAt() == b.getCreatedAt() + && a.getCategoryId() == b.getCategoryId() + && Objects.equals(a.getTitle(), b.getTitle()) + && Objects.equals(a.getDescription(), b.getDescription()) + && Objects.equals(a.getReminderTime(), b.getReminderTime()) + && a.getReminderIntervalMinutes() == b.getReminderIntervalMinutes(); } public boolean moveItem(int fromPos, int toPos) { if (!isActiveTask(fromPos) || !isActiveTask(toPos)) return false; Collections.swap(activeTasks, fromPos, toPos); Collections.swap(displayList, fromPos, toPos); + Collections.swap(displayKeys, fromPos, toPos); notifyItemMoved(fromPos, toPos); return true; } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java new file mode 100644 index 00000000..e6fb534e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java @@ -0,0 +1,118 @@ +package com.pasich.mynotes.presenter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.ui.contract.TasksContract; +import com.pasich.mynotes.ui.presenter.TasksPresenter; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +public class TasksPresenterTest extends BasePresenterTest { + + @Mock DataManager dataManager; + @Mock TasksContract.view view; + + private final TestScheduler io = new TestScheduler(); + private TasksPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + when(dataManager.getCategories()).thenReturn(Flowable.just(Collections.emptyList())); + when(dataManager.getActiveTasks()).thenReturn(Flowable.just(Collections.emptyList())); + when(dataManager.getActiveTasksByCategory(anyInt())) + .thenReturn(Flowable.just(Collections.emptyList())); + when(dataManager.getCompletedTasks()).thenReturn(Flowable.just(Collections.emptyList())); + SchedulerProvider immediate = testSchedulerProvider(); + SchedulerProvider queued = + new SchedulerProvider() { + @Override + public Scheduler ui() { + return immediate.ui(); + } + + @Override + public Scheduler computation() { + return immediate.computation(); + } + + @Override + public Scheduler io() { + return io; + } + }; + presenter = + new TasksPresenter( + queued, new CompositeDisposable(), dataManager, mock(Context.class)); + presenter.attachView(view); + presenter.viewIsReady(); + io.triggerActions(); + } + + private static Task task(int id, int position) { + Task task = new Task("T" + id, 0); + task.setId(id); + task.setPosition(position); + return task; + } + + @Test + @SuppressWarnings("unchecked") + public void updatePositions_writesOnlyTheMovedTasks_inOneCall() { + when(dataManager.updateTasks(anyList())).thenReturn(Completable.complete()); + // Task 3 dragged to the top: all three moved. + List order = new ArrayList<>(Arrays.asList(task(3, 2), task(1, 0), task(2, 1))); + + presenter.updatePositions(order); + io.triggerActions(); + + ArgumentCaptor> written = ArgumentCaptor.forClass(List.class); + verify(dataManager).updateTasks(written.capture()); + assertEquals(3, written.getValue().size()); + verify(dataManager, never()).updateTask(org.mockito.ArgumentMatchers.any()); + } + + @Test + public void updatePositions_nothingMoved_writesNothing() { + presenter.updatePositions(Arrays.asList(task(1, 0), task(2, 1))); + + verify(dataManager, never()).updateTasks(anyList()); + } + + @Test + public void switchingCategory_doesNotCancelAWriteInFlight() { + AtomicBoolean ran = new AtomicBoolean(); + when(dataManager.updateTask(org.mockito.ArgumentMatchers.any())) + .thenReturn(Completable.fromAction(() -> ran.set(true))); + + presenter.editTask(task(1, 0), "New title", null); + presenter.onCategorySelected(5); // used to clear every subscription + io.triggerActions(); + + assertTrue(ran.get()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java b/app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java new file mode 100644 index 00000000..f8ebdea3 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java @@ -0,0 +1,96 @@ +package com.pasich.mynotes.utils.adapters; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.recyclerview.widget.RecyclerView; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.utils.adapters.tasks.TasksAdapter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** A database change rebinds only the rows it touched. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class TasksAdapterDiffTest { + + private TasksAdapter adapter; + private final List events = new ArrayList<>(); + + @Before + public void setUp() { + adapter = new TasksAdapter(t -> {}, t -> {}, t -> {}, () -> {}, h -> {}, t -> {}); + adapter.registerAdapterDataObserver( + new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + events.add("all"); + } + + @Override + public void onItemRangeChanged(int start, int count, Object payload) { + events.add("changed " + start + "+" + count); + } + + @Override + public void onItemRangeInserted(int start, int count) { + events.add("inserted " + start + "+" + count); + } + + @Override + public void onItemRangeRemoved(int start, int count) { + events.add("removed " + start + "+" + count); + } + + @Override + public void onItemRangeMoved(int from, int to, int count) { + events.add("moved " + from + ">" + to); + } + }); + } + + private static Task task(int id, String title) { + Task task = new Task(title, 0); + task.setId(id); + task.setCreatedAt(1L); + return task; + } + + @Test + public void sameTasksAgain_nothingIsRebound() { + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "b")), Collections.emptyList()); + events.clear(); + + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "b")), Collections.emptyList()); + + assertThat(events).isEmpty(); + } + + @Test + public void oneTaskRenamed_onlyItsRowChanges() { + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "b")), Collections.emptyList()); + events.clear(); + + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "B")), Collections.emptyList()); + + assertThat(events).containsExactly("changed 1+1"); + } + + @Test + public void taskEditedInPlaceThenReloaded_rowIsRebound() { + Task bound = task(1, "a"); + adapter.submitList(Collections.singletonList(bound), Collections.emptyList()); + events.clear(); + + bound.setTitle("edited"); // what TasksPresenter.editTask does before writing + adapter.submitList(Collections.singletonList(task(1, "edited")), Collections.emptyList()); + + assertThat(events).containsExactly("changed 0+1"); + } +} From a792a3c14d2048351f4ca437b885b547eb6df181 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:39:26 +0200 Subject: [PATCH 31/52] fix(editor): keep a long simple-editor draft out of the saved state 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. --- .../activity/noteEditor/NoteActivity.java | 37 ++++++++++++++++++- .../SimpleEditorDraftStateTest.java | 32 ++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java index 95deadb7..8fb1d81d 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java @@ -27,6 +27,7 @@ import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import androidx.core.widget.NestedScrollView; +import androidx.lifecycle.ViewModelProvider; import com.google.android.material.chip.Chip; import com.pasich.mynotes.R; import com.pasich.mynotes.base.simplifications.TextWatcher; @@ -40,6 +41,7 @@ import com.pasich.mynotes.utils.editor.NoteViewState; import com.pasich.mynotes.utils.editor.NoteViewStateStore; import com.pasich.mynotes.utils.editor.PositionRestorer; +import com.pasich.mynotes.utils.editor.RetainedNoteDraft; import com.pasich.mynotes.utils.editor.TextEditHistory; import dagger.hilt.android.AndroidEntryPoint; import javax.inject.Inject; @@ -70,6 +72,9 @@ public class NoteActivity extends BaseNoteEditorActivity { */ private static final int MAX_HISTORY_STATE_CHARS = 32 * 1024; + /** Largest unsaved title and body kept in the saved state, as in the extended editor. */ + static final int MAX_DRAFT_CHARS = 64 * 1024; + /** * How long typing has to pause before the body is copied out of the field. Copying, counting * words and comparing are linear in the note's length; doing them on every keystroke made @@ -102,6 +107,7 @@ public class NoteActivity extends BaseNoteEditorActivity { private int restoredAnchorOffset = EditorCursor.NONE; private String draftTitle; private String draftValue; + private RetainedNoteDraft retainedDraft; // Undo and redo for the title and the body. Changes made by the app itself (loading the note, // applying an undo) are not recorded. @@ -135,6 +141,16 @@ public void onCreate(Bundle savedInstanceState) { readRestoredState(savedInstanceState); restoreSavedPosition = savedInstanceState == null; super.onCreate(savedInstanceState); + retainedDraft = new ViewModelProvider(this).get(RetainedNoteDraft.class); + if (savedInstanceState != null && notePresenter != null) { + // A draft too long for the saved state was kept in memory instead. The note itself + // loads asynchronously, after this. + RetainedNoteDraft.Draft kept = retainedDraft.take(notePresenter.getIdKey()); + if (kept != null && draftTitle == null && draftValue == null) { + draftTitle = kept.title; + draftValue = kept.body; + } + } } private void readRestoredState(@Nullable Bundle state) { @@ -180,6 +196,17 @@ private void saveEditHistory(Bundle outState) { outState.putInt(STATE_HISTORY_BODY_HASH, snapshot.bodyHash); } + /** + * Whether an unsaved draft is small enough for the saved state. The body has no length limit, + * and the saved state shares a size limit with the whole screen: a long note there crashed the + * app (TransactionTooLargeException). A bigger draft is kept in memory ({@link + * RetainedNoteDraft}) for a recreation; onStop has already started writing it. + */ + static boolean draftFitsSavedState(String title, String value) { + long length = (title != null ? title.length() : 0) + (value != null ? value.length() : 0); + return length <= MAX_DRAFT_CHARS; + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); @@ -194,8 +221,14 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { // The fields do not save their own text (see initListeners); a draft is kept only while a // save is still on its way, so the next instance never shows older text than was typed. if (notePresenter != null && notePresenter.hasUnsavedChanges()) { - outState.putString(STATE_DRAFT_TITLE, binding.notesTitle.getText().toString()); - outState.putString(STATE_DRAFT_VALUE, binding.valueNote.getText().toString()); + String title = binding.notesTitle.getText().toString(); + String value = binding.valueNote.getText().toString(); + if (draftFitsSavedState(title, value)) { + outState.putString(STATE_DRAFT_TITLE, title); + outState.putString(STATE_DRAFT_VALUE, value); + } else if (retainedDraft != null) { + retainedDraft.put(notePresenter.getIdKey(), title, value); + } } saveEditHistory(outState); } diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java new file mode 100644 index 00000000..da92621e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java @@ -0,0 +1,32 @@ +package com.pasich.mynotes.ui.view.activity.noteEditor; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * The simple editor has no length limit, so an unsaved draft is put in the saved state only while + * it is small: a long note there crashed the app with TransactionTooLargeException. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SimpleEditorDraftStateTest { + + @Test + public void aShortDraftGoesInTheSavedState() { + assertThat(NoteActivity.draftFitsSavedState("title", "x".repeat(1000))).isTrue(); + assertThat(NoteActivity.draftFitsSavedState(null, null)).isTrue(); + } + + @Test + public void aLongDraftIsLeftOut() { + assertThat(NoteActivity.draftFitsSavedState("", "x".repeat(NoteActivity.MAX_DRAFT_CHARS))) + .isTrue(); + assertThat(NoteActivity.draftFitsSavedState("t", "x".repeat(NoteActivity.MAX_DRAFT_CHARS))) + .isFalse(); + assertThat(NoteActivity.draftFitsSavedState("", "x".repeat(500_000))).isFalse(); + } +} From ef17db0d8c02794f7174bd34d31c2f325af3f43d Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:39:31 +0200 Subject: [PATCH 32/52] fix(encly): clear only what was handed to Encly, and only when it took 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. --- .../ui/view/activity/MeetEnclyActivity.java | 49 +++++++++- .../utils/encly/EnclyMigrationRepository.java | 89 +++++++++++++++++-- .../encly/EnclyExportedSnapshotTest.java | 60 +++++++++++++ 3 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java index 83103967..98c4f2a9 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java @@ -62,6 +62,10 @@ public class MeetEnclyActivity extends BaseActivity { private static final String STATE_SKIPPED = "encly_skipped"; private static final String STATE_REASON = "encly_reason"; private static final String STATE_CLEARED = "encly_cleared"; + private static final String STATE_EXPORTED_NOTES = "encly_exported_notes"; + private static final String STATE_EXPORTED_TASKS = "encly_exported_tasks"; + private static final String STATE_EXPORTED_TAGS = "encly_exported_tags"; + private static final String STATE_EXPORTED_CATEGORIES = "encly_exported_categories"; @Inject EnclyMigrationRepository repository; @Inject UpdateChecker updateChecker; @@ -80,6 +84,9 @@ public class MeetEnclyActivity extends BaseActivity { @Nullable private String resultReason; private boolean cleared; + /** What the last hand-off carried; the only records a clear may delete. */ + @Nullable private EnclyMigrationRepository.Exported exported; + private final ActivityResultLauncher enclyLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), this::onEnclyResult); @@ -274,7 +281,7 @@ private void renderResult() { } else { binding.resultText.setVisibility(View.GONE); } - binding.clearGroup.setVisibility(cleared ? View.GONE : View.VISIBLE); + binding.clearGroup.setVisibility(cleared || !canClear() ? View.GONE : View.VISIBLE); binding.clearButton.setEnabled(!busy); } else { setResultIcon( @@ -342,12 +349,18 @@ private void startHandoff(@NonNull String activityClassName) { Single.fromCallable( () -> { repository.clearArchives(); - return repository.writeArchive(repository.load()); + EnclyMigrationRepository.Library library = repository.load(); + return new android.util.Pair<>( + repository.writeArchive(library), + EnclyMigrationRepository.Exported.of(library)); }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - file -> launchEncly(file, activityClassName), + prepared -> { + exported = prepared.second; + launchEncly(prepared.first, activityClassName); + }, error -> { Log.e(TAG, "prepare hand-off", error); clearArchivesInBackground(); @@ -446,10 +459,15 @@ private void confirmClear() { } private void clearMyNotes() { + EnclyMigrationRepository.Exported snapshot = exported; + if (snapshot == null || !canClear()) { + render(); + return; + } busy = true; render(); disposables.add( - Completable.fromAction(repository::clearAllData) + Completable.fromAction(() -> repository.clearExported(snapshot)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( @@ -494,10 +512,33 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(STATE_SKIPPED, resultCounts[3]); outState.putString(STATE_REASON, resultReason); outState.putBoolean(STATE_CLEARED, cleared); + if (exported != null) { + // Saved before Encly is opened, so the snapshot survives this process being killed + // while Encly is in front. + outState.putLongArray(STATE_EXPORTED_NOTES, exported.notes); + outState.putLongArray(STATE_EXPORTED_TASKS, exported.tasks); + outState.putLongArray(STATE_EXPORTED_TAGS, exported.tags); + outState.putLongArray(STATE_EXPORTED_CATEGORIES, exported.categories); + } + } + + /** + * Whether clearing My Notes may be offered: only for what the hand-off carried, and only when + * Encly took all of it. With records skipped, some of the library exists nowhere but here. + */ + private boolean canClear() { + return exported != null && result != null && result.success && result.skipped == 0; } private void restoreResult(@NonNull Bundle state) { cleared = state.getBoolean(STATE_CLEARED, false); + long[] notes = state.getLongArray(STATE_EXPORTED_NOTES); + long[] tasks = state.getLongArray(STATE_EXPORTED_TASKS); + long[] tags = state.getLongArray(STATE_EXPORTED_TAGS); + long[] categories = state.getLongArray(STATE_EXPORTED_CATEGORIES); + if (notes != null && tasks != null && tags != null && categories != null) { + exported = new EnclyMigrationRepository.Exported(notes, tasks, tags, categories); + } if (!state.getBoolean(STATE_HAS_RESULT, false)) return; resultCode = state.getInt(STATE_RESULT_CODE); resultCounts = diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java index bd82f64f..55ac23e2 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java @@ -2,6 +2,7 @@ import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; @@ -17,9 +18,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext; import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.ToLongFunction; import javax.inject.Inject; import javax.inject.Singleton; @@ -97,25 +102,93 @@ public void clearArchives() { } /** - * Deletes notes, tasks, tags, task categories and attachment files. Only ever called after - * Encly confirmed the move and the user confirmed this separate step. + * Which records a hand-off carried. + * + *

The clear offered afterwards removes exactly these. It used to remove whatever the + * database held when the button was pressed, including a note written, or synced in, after the + * archive was made — which never reached Encly. + */ + public static final class Exported { + public final long[] notes; + public final long[] tasks; + public final long[] tags; + public final long[] categories; + + public Exported(long[] notes, long[] tasks, long[] tags, long[] categories) { + this.notes = notes; + this.tasks = tasks; + this.tags = tags; + this.categories = categories; + } + + @NonNull + public static Exported of(@NonNull Library library) { + return new Exported( + ids(library.notes, Note::getId), + ids(library.tasks, Task::getId), + ids(library.tags, Tag::getId), + ids(library.categories, TaskCategory::getId)); + } + + private static long[] ids(List items, ToLongFunction id) { + if (items == null) return new long[0]; + long[] out = new long[items.size()]; + for (int i = 0; i < out.length; i++) out[i] = id.applyAsLong(items.get(i)); + return out; + } + } + + /** The items of {@code current} whose id is among {@code exported}. */ + @NonNull + static List onlyExported( + @Nullable List current, @NonNull long[] exported, @NonNull ToLongFunction id) { + List out = new ArrayList<>(); + if (current == null || exported.length == 0) return out; + Set wanted = new HashSet<>(); + for (long value : exported) wanted.add(value); + for (T item : current) { + if (wanted.contains(id.applyAsLong(item))) out.add(item); + } + return out; + } + + /** + * Deletes the notes, tasks, tags and task categories the hand-off carried, with the notes' + * attachment files. Only ever called after Encly confirmed the move and the user confirmed this + * separate step. Anything added since the archive was made stays. */ @WorkerThread - public void clearAllData() { + public void clearExported(@NonNull Exported exported) { Library library = load(); - for (Note note : library.notes) { + List notes = onlyExported(library.notes, exported.notes, Note::getId); + List tasks = onlyExported(library.tasks, exported.tasks, Task::getId); + List tags = onlyExported(library.tags, exported.tags, Tag::getId); + List categories = + onlyExported(library.categories, exported.categories, TaskCategory::getId); + + for (Note note : notes) { if (note.getReminderTime() != null) { ReminderManager.cancelReminder(context, note.getId()); } } - for (Task task : library.tasks) { + for (Task task : tasks) { if (task.getReminderTime() != null) { TaskReminderManager.cancelReminder(context, task.getId()); } } - syncMutationCoordinator.clearAllUserData(); - new NoteViewStateStore(context).clear(); - deleteRecursively(AttachmentStorage.baseDirPath(context)); + database.runInTransaction(() -> { + syncMutationCoordinator.deleteNotes(notes); + for (Task task : tasks) syncMutationCoordinator.deleteTask(task); + for (Tag tag : tags) syncMutationCoordinator.deleteTag(tag); + for (TaskCategory category : categories) + syncMutationCoordinator.deleteCategory(category); + }); + NoteViewStateStore viewState = new NoteViewStateStore(context); + File attachments = AttachmentStorage.baseDirPath(context); + for (Note note : notes) { + viewState.remove(note.getId()); + deleteRecursively(new File(attachments, "note_" + note.getId())); + } } private static String key(String recordType, long localId) { diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java new file mode 100644 index 00000000..564222ff --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java @@ -0,0 +1,60 @@ +package com.pasich.mynotes.utils.encly; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.pasich.mynotes.data.model.Note; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +/** The clear after a hand-off deletes what the hand-off carried, and nothing added since. */ +public class EnclyExportedSnapshotTest { + + private static Note note(int id) { + Note note = new Note(); + note.setId(id); + return note; + } + + @Test + public void keepsOnlyTheRecordsTheHandoffCarried() { + List now = Arrays.asList(note(1), note(2), note(3), note(9)); + + List toDelete = + EnclyMigrationRepository.onlyExported(now, new long[] {1, 3, 4}, Note::getId); + + assertEquals(2, toDelete.size()); + assertEquals(1, toDelete.get(0).getId()); + assertEquals(3, toDelete.get(1).getId()); + } + + @Test + public void nothingExported_deletesNothing() { + assertTrue( + EnclyMigrationRepository.onlyExported( + Arrays.asList(note(1), note(2)), new long[0], Note::getId) + .isEmpty()); + assertTrue( + EnclyMigrationRepository.onlyExported(null, new long[] {1}, Note::getId).isEmpty()); + } + + @Test + public void snapshot_recordsEveryIdOfTheLibrary() { + EnclyMigrationRepository.Library library = + new EnclyMigrationRepository.Library( + Arrays.asList(note(5), note(6)), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>()); + + EnclyMigrationRepository.Exported exported = EnclyMigrationRepository.Exported.of(library); + + assertArrayEquals(new long[] {5, 6}, exported.notes); + assertEquals(0, exported.tasks.length); + assertEquals(0, exported.tags.length); + assertEquals(0, exported.categories.length); + } +} From 4745744188eea720a26ba88419f2b024037a0225 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:42:45 +0200 Subject: [PATCH 33/52] perf(main): read the notes once, sort off the main thread, rest while 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. --- .../mynotes/ui/contract/MainContract.java | 6 ++ .../mynotes/ui/presenter/MainPresenter.java | 101 +++++++++++------- .../ui/view/activity/MainActivity.java | 3 + .../mynotes/presenter/MainPresenterTest.java | 35 +++++- 4 files changed, 108 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java index f9721465..e0067417 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/MainContract.java @@ -54,6 +54,12 @@ interface view extends BaseView, MoreNoteMainActivityView { @ActivityScoped interface presenter extends BasePresenter { + /** The screen became visible: search and statistics follow the database again. */ + void onViewStarted(); + + /** The screen is hidden: stop re-running search and statistics until it is back. */ + void onViewStopped(); + /** Handles the new-note FAB click. */ void newNotesClick(); diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java index a1fa8bd5..465953cb 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/MainPresenter.java @@ -21,6 +21,7 @@ import dagger.hilt.android.scopes.ActivityScoped; import io.reactivex.Flowable; import io.reactivex.Observable; +import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.subjects.BehaviorSubject; import java.util.ArrayList; @@ -78,23 +79,52 @@ public void viewIsReady() { initStreams(); startStateCombiner(); starListsRenderStream(); + startLiveStreams(); + } + + /** + * Search and drawer statistics, live only while the screen is visible: behind the editor every + * autosave would otherwise re-rank the notes and re-count every character for nothing. Null + * while stopped. + * + *

The list itself stays live: coming back from the editor it must already show the edit when + * the return transition starts, which a re-query on return could not guarantee. + */ + private CompositeDisposable liveStreams; + + @Override + public void onViewStarted() { + if (isViewAttached()) startLiveStreams(); + } + + @Override + public void onViewStopped() { + if (liveStreams == null) return; + getCompositeDisposable().remove(liveStreams); // disposes it + liveStreams = null; + } + + private void startLiveStreams() { + if (liveStreams != null || tagsStream == null) return; + liveStreams = new CompositeDisposable(); + getCompositeDisposable().add(liveStreams); + // The query and tag filter live in subjects, so a restart picks up where it stopped. startSearchStream(); startStatsStream(); } void startStatsStream() { - getCompositeDisposable() - .add( - Flowable.combineLatest( - getDataManager().getNotesCount(), - getDataManager().getNotesCreatedLastMonth(), - getDataManager().getTotalCharacters(), - StatsData::new) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - stats -> getView().renderDrawerStats(stats), - error -> Log.e(TAG, "drawer stats stream error", error))); + liveStreams.add( + Flowable.combineLatest( + getDataManager().getNotesCount(), + getDataManager().getNotesCreatedLastMonth(), + getDataManager().getTotalCharacters(), + StatsData::new) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + stats -> getView().renderDrawerStats(stats), + error -> Log.e(TAG, "drawer stats stream error", error))); } void starListsRenderStream() { @@ -115,19 +145,25 @@ private void initStreams() { .getTags() .toObservable() .map(tagList -> - TagsSorter.sortTags(tagList, getDataManager().getSortParamTags())); + TagsSorter.sortTags(tagList, getDataManager().getSortParamTags())) + .replay(1) + .refCount(); - notesStream = getDataManager().getNotes().toObservable(); + // Shared by the list and the search: every note body is read once per change, not twice. + notesStream = getDataManager().getNotes().toObservable().replay(1).refCount(); } private void startStateCombiner() { + Scheduler computation = getSchedulerProvider().computation(); getCompositeDisposable() .add( Observable.combineLatest( tagsStream, notesStream, - selectedTag, - sortParam, + // A tap on a chip or a sort option emits on the main + // thread; the filter and sort of every note must not. + selectedTag.observeOn(computation), + sortParam.observeOn(computation), this::buildState) .debounce( STATE_DEBOUNCE_MS, @@ -263,26 +299,19 @@ private static boolean sameSelection(Tag a, Tag b) { } private void startSearchStream() { - getCompositeDisposable() - .add( - Observable.combineLatest( - notesStream, - searchQuery, - searchTagFilter, - SearchRequest::new) - .debounce( - SEARCH_DEBOUNCE_MS, - TimeUnit.MILLISECONDS, - getSchedulerProvider().computation()) - .map(this::runSearch) - .subscribeOn(getSchedulerProvider().io()) - .observeOn(getSchedulerProvider().ui()) - .subscribe( - result -> - getView() - .renderSearch( - result.hits, result.titlesOnly), - throwable -> Log.e(TAG, "search error", throwable))); + liveStreams.add( + Observable.combineLatest( + notesStream, searchQuery, searchTagFilter, SearchRequest::new) + .debounce( + SEARCH_DEBOUNCE_MS, + TimeUnit.MILLISECONDS, + getSchedulerProvider().computation()) + .map(this::runSearch) + .subscribeOn(getSchedulerProvider().io()) + .observeOn(getSchedulerProvider().ui()) + .subscribe( + result -> getView().renderSearch(result.hits, result.titlesOnly), + throwable -> Log.e(TAG, "search error", throwable))); } /** Ranks off the main thread: the debounce hands the latest request to a worker. */ diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java index 790205ba..023cee5c 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java @@ -442,6 +442,7 @@ private void renderNotes(List notes, Tag selectedTag, UiEvent event) { @Override protected void onStart() { super.onStart(); + mainPresenter.onViewStarted(); if (stopped) { stopped = false; // Coming back, usually from the editor with a shared-element return transition. @@ -476,6 +477,8 @@ protected void onStop() { settling = false; // Anything held back is applied now, without animation, while nobody is looking. applyPendingState(); + // Search and statistics are not re-run behind the editor; they catch up on return. + mainPresenter.onViewStopped(); } private float animatorDurationScale() { diff --git a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java index 52924f35..70674f3b 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java @@ -112,13 +112,16 @@ public void detachView_afterAsyncCallback_doesNotThrowNPE() { private final TestScheduler debounceClock = new TestScheduler(); private BehaviorProcessor> tagsDb; private BehaviorProcessor> notesDb; + private final java.util.concurrent.atomic.AtomicInteger notesQueries = + new java.util.concurrent.atomic.AtomicInteger(); /** Presenter wired to processors standing in for Room, with a controllable debounce clock. */ private MainPresenter syncPresenter(List tags, List notes) { tagsDb = BehaviorProcessor.createDefault(tags); notesDb = BehaviorProcessor.createDefault(notes); when(mockDataManager.getTags()).thenReturn(tagsDb); - when(mockDataManager.getNotes()).thenReturn(notesDb); + when(mockDataManager.getNotes()) + .thenReturn(notesDb.doOnSubscribe(s -> notesQueries.incrementAndGet())); when(mockDataManager.getSortParam()).thenReturn(SortParam.DataSort); SchedulerProvider provider = new SchedulerProvider() { @@ -323,6 +326,36 @@ public void search_ranksExactTitleFirstAfterDebounce() { assertThat(hits.get(0).kind()).isEqualTo(SearchHit.MatchKind.EXACT_TITLE); } + @Test + public void notes_areReadOnceForTheListAndTheSearch() { + syncPresenter(tagsWith(), Collections.emptyList()); + + assertThat(notesQueries.get()).isEqualTo(1); + } + + @Test + public void whileStopped_searchIsNotRerun_andCatchesUpOnStart() { + MainPresenter p = + syncPresenter(tagsWith(), Arrays.asList(new Note().create("PR", "", 10, ""))); + p.updateSearchQuery("PR"); + settle(); + clearInvocations(mockView); + + p.onViewStopped(); + notesDb.onNext( + Arrays.asList( + new Note().create("PR", "", 10, ""), + new Note().create("PR 2", "", 20, ""))); + settle(); + verify(mockView, never()).renderSearch(any(), anyBoolean()); + // The list itself keeps following the database, ready for the return transition. + assertThat(lastRendered().notes()).hasSize(2); + + p.onViewStarted(); + settle(); + assertThat(lastSearch(false)).hasSize(2); + } + @Test public void search_oneCharacterAsksToKeepTyping() { MainPresenter p = From 29578c7d3c317477d6a47f6ba33f199b1c6d6077 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:44:19 +0200 Subject: [PATCH 34/52] fix(share): data export opens its dialog through newInstance; VIEW takes content:// only BackupActivity used the deprecated ShareOptionsDialog constructor. ShareActivity's VIEW filter still advertised file:// URIs, which ShareProcessor now refuses. --- app/src/main/AndroidManifest.xml | 1 - .../com/pasich/mynotes/ui/view/activity/BackupActivity.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14b61180..14ae1ee6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -141,7 +141,6 @@ - diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java index 8f76050d..708bce8b 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java @@ -1223,7 +1223,7 @@ public void initListeners() {} @Override public void openShareOptionsDialog(java.util.List notes, boolean isDataExport) { - ShareOptionsDialog shareOptionsDialog = new ShareOptionsDialog(notes, isDataExport); + ShareOptionsDialog shareOptionsDialog = ShareOptionsDialog.newInstance(notes, isDataExport); shareOptionsDialog.show(getSupportFragmentManager(), "ShareOptionsDialog"); } From dbb27cc744959dfbbbd07ecf2edb58225ab4671e Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 15:58:54 +0200 Subject: [PATCH 35/52] fix(sync): enable sync only after Drive authorization succeeds 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. --- .../mynotes/ui/sync/SyncCoordinator.java | 31 ++++++-- .../mynotes/ui/sync/SyncCoordinatorTest.java | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java index 75c0d876..c683bc41 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java @@ -20,12 +20,20 @@ import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicLong; /** Coordinates connect/disconnect/manual-sync flows so BackupActivity stays presentation-only. */ public final class SyncCoordinator { private static final String TAG = "SyncCoordinator"; + /** + * Bumped by every {@link #disconnect}. Process-wide because the sync flags it guards are + * process-wide too: a coordinator rebuilt for a recreated screen must still see a disconnect + * made through the old one. + */ + private static final AtomicLong disconnectGeneration = new AtomicLong(); + public interface Callback { void onSuccess(@NonNull T value); @@ -210,6 +218,7 @@ public void onError(@NonNull Exception error) { } public void disconnect(@NonNull Callback callback) { + disconnectGeneration.incrementAndGet(); firebaseGoogleAuth.signOut(); preferenceHelper.setSyncEnabled(false); preferenceHelper.setBackgroundSyncEnabled(false); @@ -255,17 +264,27 @@ public void syncNow(@NonNull Activity activity, @NonNull Callback cal new IllegalStateException("Confirm the first sync before continuing")); return; } - // Enabled here, on the calling thread and after the checks above, never on the worker. - // SyncService re-reads this flag under its lock precisely so a disconnect that lands while - // this sync waits for the token or the lock stops it; switching it back on from the worker - // just before the sync undid that disconnect and synced the signed-out account anyway. - // disconnect() runs on the same thread as this method, so the two cannot interleave here. - preferenceHelper.setSyncEnabled(true); + // Sync is switched on only once Drive authorization succeeds, so a declined or failed + // authorization leaves the flag as it was. It is switched on from the authorization + // callback (main thread, like disconnect()) and never from the worker: SyncService re-reads + // the flag under its lock so a disconnect that lands while the sync waits in the queue + // stops it, and turning it on from the worker undid that disconnect. A disconnect made + // between this tap and the authorization result is caught by the generation check. + final long generationAtTap = disconnectGeneration.get(); googleDriveAuthorization.authorize( activity, new GoogleDriveAuthorization.Callback() { @Override public void onAuthorized(@NonNull String accessToken) { + if (disconnectGeneration.get() != generationAtTap + || !firebaseGoogleAuth.isSignedIn()) { + deliverError( + callback, + new IllegalStateException( + "Sync was disconnected before it could start")); + return; + } + preferenceHelper.setSyncEnabled(true); runOnWorker( callback, () -> { diff --git a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java index 88b1293f..b639152d 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java @@ -345,6 +345,79 @@ public void syncNow_queuedBehindADisconnectDoesNotTurnSyncBackOn() { assertThat(preferences.syncEnabled).isFalse(); } + @Test + public void syncNow_declinedAuthorizationLeavesSyncDisabled() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onError(new IllegalStateException("cancelled")); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + directExecutor, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + + assertThat(callback.error).isNotNull(); + assertThat(preferences.syncEnabled).isFalse(); + assertThat(store.lastToken).isNull(); + } + + @Test + public void syncNow_disconnectWhileAuthorizingDoesNotTurnSyncBackOn() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + List pending = new ArrayList<>(); + Mockito.doAnswer( + invocation -> { + pending.add(invocation.getArgument(1)); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + directExecutor, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + assertThat(preferences.syncEnabled).isFalse(); + coordinator.disconnect(new CapturingCallback<>()); + pending.get(0).onAuthorized("access-token"); + + assertThat(preferences.syncEnabled).isFalse(); + assertThat(store.lastToken).isNull(); + assertThat(callback.error).isNotNull(); + } + @Test public void resolveConflict_updatesStoreAndReturnsLatestConflicts() { FakePreferenceHelper preferences = new FakePreferenceHelper(); From 6bd4eb6f53a52f71368fc86446caf06f63f1a95b Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:01:04 +0200 Subject: [PATCH 36/52] fix(backup): tell the user when a restore is refused because one is running 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). --- .../mynotes/ui/presenter/BackupPresenter.java | 11 +++++++- app/src/main/res/values-be/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en-rGB/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-kk/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../presenter/BackupPresenterRestoreTest.java | 28 +++++++++++++++++++ 13 files changed, 49 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java index 19e26129..c0cc0d84 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java @@ -169,8 +169,11 @@ public void writeFileBackupLocal(Uri mUri) { @Override public void readFileBackupLocal(Uri mUri) { if (BackgroundWork.restoreRunning) { - // One restore at a time; the running one reports to whichever screen is current. + // One restore at a time; the running one reports to whichever screen is current. The + // newly picked backup is not restored, so say so instead of dropping it silently + // behind a progress dialog that belongs to the other restore. getView().showProcessRestoreDialog(); + rejectRestoreWhileRunning(); return; } getView().showProcessRestoreDialog(); @@ -204,6 +207,7 @@ public void readFileBackupLocal(Uri mUri) { */ private void restoreData(JsonBackup jsonBackup) { if (BackgroundWork.restoreRunning) { + rejectRestoreWhileRunning(); return; } BackgroundWork.restoreRunning = true; @@ -245,6 +249,11 @@ private Completable restoreCompletable(JsonBackup jsonBackup) { }); } + /** Tells the user a second restore was not started because one is already running. */ + private void rejectRestoreWhileRunning() { + runOnView(view -> view.showErrorsText(0, R.string.restore_already_running)); + } + private void runInTransaction(Runnable body) { if (appDatabase != null) { appDatabase.runInTransaction(body); diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index d713ea46..cec0fc65 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -91,6 +91,7 @@ Выбраць рэз. копію Аднавіць з аблокі Аднаўленне дадзеных \nПасля завяршэння дадзенае акно будзе аўтаматычна зачынена. + Іншае аднаўленне яшчэ выконваецца. Выберыце рэзервовую копію зноў, калі яно завершыцца. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ef28e731..3f13a084 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -84,6 +84,7 @@ Auflösung auswählen. kopieren Aus Cloud wiederherstellen Daten wiederherstellen \nDieses Fenster wird nach Abschluss automatisch geschlossen. + Eine andere Wiederherstellung läuft noch. Wähle die Sicherung erneut aus, sobald sie abgeschlossen ist. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 152d90b7..e40756e3 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -105,6 +105,7 @@ Select backup Restore from cloud Restoring data\nThis window will close automatically when finished. + Another restore is still running. Pick the backup again once it has finished. No data to back up Backup created successfully diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f0c09fcd..078ff9dd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -94,6 +94,7 @@ Seleccione Restaurar. copiar Restaurar desde la nube Restaurando datos \nEsta ventana se cerrará automáticamente cuando se complete. + Otra restauración sigue en curso. Vuelve a elegir la copia de seguridad cuando termine. No hay datos para respaldar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e63d659f..d83fcaa2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -90,6 +90,7 @@ Sélectionner res. copier Restauration à partir du cloud Restaurer les données \nCette fenêtre se fermera automatiquement une fois terminée. + Une autre restauration est encore en cours. Sélectionnez à nouveau la sauvegarde une fois celle-ci terminée. Il n\'y a pas de données à sauvegarder diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 28cf66a7..826ba956 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -89,6 +89,7 @@ Seleziona res. copia Ripristina dal cloud Ripristina dati \nQuesta finestra si chiuderà automaticamente al termine. + Un altro ripristino è ancora in corso. Seleziona di nuovo il backup al termine. Non ci sono dati di cui eseguire il backup diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 02e35d50..4ec61913 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -91,6 +91,7 @@ Қалпына келтіру опциясын таңдаңыз. көшіру Бұлттан қалпына келтіру Деректерді қалпына келтіру \nБұл терезе аяқталғаннан кейін автоматты түрде жабылады. + Басқа қалпына келтіру әлі орындалуда. Ол аяқталғаннан кейін сақтық көшірмені қайта таңдаңыз. Сақтық көшірме жасау үшін деректер жоқ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 2a3d154d..0c090348 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -92,6 +92,7 @@ Wybierz opcję Przywróć. skopiuj Przywróć z chmury Przywracanie danych \nTo okno zamknie się automatycznie po zakończeniu. + Inne przywracanie wciąż trwa. Wybierz kopię zapasową ponownie, gdy się zakończy. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fb92f0c6..80e9d1e5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -94,6 +94,7 @@ Выбрать рез. копию Восстановить из облака Восстановление данных \nПо завершении это окно закроется автоматически. + Другое восстановление ещё выполняется. Выберите резервную копию снова, когда оно завершится. Нет данных, для создания резервной копии diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d54d659a..e981610e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -91,6 +91,7 @@ Вибрати рез. копію Відновити з хмари Відновлення даних \nПісля завершення це вікно буде автоматично закрито. + Інше відновлення ще триває. Виберіть резервну копію знову, коли його буде завершено. Немає даних для створення резервної копії diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d18644f..13e90b78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,7 @@ Select Restore. copy Restore from Cloud Restoring data \nThis window will close automatically when completed. + Another restore is still running. Pick the backup again once it finishes. No data to backup diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java index e12eed12..c44d6a0b 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import com.google.gson.Gson; +import com.pasich.mynotes.R; import com.pasich.mynotes.base.BasePresenterTest; import com.pasich.mynotes.data.DataManager; import com.pasich.mynotes.ui.contract.BackupContract; @@ -130,4 +131,31 @@ public void failedRestore_isReportedNotSwallowed() { verify(view, never()).onRestoreSuccessFlag(); verify(dataManager, never()).setListPreferences(any()); } + + @Test + public void pickingABackupWhileARestoreRuns_tellsTheUserInsteadOfDroppingIt() { + BackupContract.view view = mock(BackupContract.view.class); + BackupPresenter presenter = presenter(view); + presenter.importDataOtherApp(oneKeepNote()); + + presenter.readFileBackupLocal(null); + + verify(view).showErrorsText(0, R.string.restore_already_running); + verify(dataManager, never()).readBackupLocalFile(any()); + io.triggerActions(); + verify(view).restoreFinish(CloudErrors.OKAY_RESTORE); + } + + @Test + public void secondKeepImportWhileARestoreRuns_tellsTheUser() { + BackupContract.view view = mock(BackupContract.view.class); + BackupPresenter presenter = presenter(view); + presenter.importDataOtherApp(oneKeepNote()); + + presenter.importDataOtherApp(oneKeepNote()); + + verify(view).showErrorsText(0, R.string.restore_already_running); + io.triggerActions(); + verify(view).restoreFinish(CloudErrors.OKAY_RESTORE); + } } From 60d901311253977d9b5ccdf58d988b9957c2b8c6 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:02:54 +0200 Subject: [PATCH 37/52] fix(editor): keep a long draft in memory only for a recreation 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. --- .../activity/noteEditor/NoteActivity.java | 7 ++- .../NoteExtendedEditorActivity.java | 35 +++++++++++++-- .../utils/editor/RetainedNoteDraft.java | 25 +++++++++++ .../utils/editor/RetainedNoteDraftTest.java | 44 +++++++++++++++++++ 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java index 8fb1d81d..4a4a8ae7 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteActivity.java @@ -220,6 +220,8 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(STATE_ANCHOR_OFFSET, readingAnchorOffset()); // The fields do not save their own text (see initListeners); a draft is kept only while a // save is still on its way, so the next instance never shows older text than was typed. + // A long draft is kept in memory only for a recreation; see RetainedNoteDraft. + boolean keptInMemory = false; if (notePresenter != null && notePresenter.hasUnsavedChanges()) { String title = binding.notesTitle.getText().toString(); String value = binding.valueNote.getText().toString(); @@ -227,9 +229,12 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putString(STATE_DRAFT_TITLE, title); outState.putString(STATE_DRAFT_VALUE, value); } else if (retainedDraft != null) { - retainedDraft.put(notePresenter.getIdKey(), title, value); + retainedDraft.keepForRecreation( + isChangingConfigurations(), notePresenter.getIdKey(), title, value); + keptInMemory = true; } } + if (!keptInMemory && retainedDraft != null) retainedDraft.discard(); saveEditHistory(outState); } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java index 66688f6a..6b895ea1 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/noteEditor/NoteExtendedEditorActivity.java @@ -95,6 +95,10 @@ public class NoteExtendedEditorActivity private String draftJson; private RetainedEditHistory retainedEditHistory; private RetainedNoteDraft retainedDraft; + // Whether this screen has taken any draft kept for it and shown the note. + private boolean noteShown; + // Whether this screen's page, released for a recreation, has handed over its last edit. + private boolean pageHandedOver; @Override public void onCreate(Bundle savedInstanceState) { @@ -168,9 +172,18 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putString(STATE_DRAFT_TITLE, note.getTitle()); outState.putString(STATE_DRAFT_JSON, json); } else { - retainedDraft.put(notePresenter.getIdKey(), note.getTitle(), json); + // Kept in memory only for a recreation; see RetainedNoteDraft. + retainedDraft.keepForRecreation( + isChangingConfigurations(), + notePresenter.getIdKey(), + note.getTitle(), + json); + return; } } + // Before the note is shown, a draft kept by the screen this one replaced is still waiting + // for it. + if (noteShown && !isChangingConfigurations()) retainedDraft.discard(); } @Override @@ -482,8 +495,16 @@ private void processTitleChange(String title) { * that screen, which waits for this page to finish before it shows the note. */ private void keepForRecreatedScreen(String json) { - if (json != null && retainedDraft != null && isChangingConfigurations()) { - retainedDraft.put(notePresenter.getIdKey(), null, json); + // A document arriving after the page has finished its hand-over (it gave up waiting) + // would wait here for no screen and be applied over newer content by a later one. + if (json != null + && retainedDraft != null + && isChangingConfigurations() + && !pageHandedOver) { + // With the title held now, so the kept draft is one consistent version. + Note held = notePresenter.getNote(); + retainedDraft.put( + notePresenter.getIdKey(), held != null ? held.getTitle() : null, json); } } @@ -572,7 +593,12 @@ public void onDestroy() { if (isChangingConfigurations() && retainedDraft != null) { RetainedNoteDraft draft = retainedDraft; draft.previousPageFinishing(); - binding.noteEditor.release(true, draft::previousPageFinished); + binding.noteEditor.release( + true, + () -> { + pageHandedOver = true; + draft.previousPageFinished(); + }); } else { binding.noteEditor.release(false); } @@ -621,6 +647,7 @@ public void loadingNote(Note note) { } private void showNote(Note note, String pendingTitle, String pendingJson) { + noteShown = true; RetainedNoteDraft.Draft kept = retainedDraft.take(note.getId()); if (kept != null) { // Newer than the saved state: it arrived as, or after, the state was saved. diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java index 32630975..cf215add 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java @@ -51,6 +51,31 @@ public void put(long noteId, @Nullable String title, @Nullable String body) { if (body != null) this.body = body; } + /** + * Called as the screen saves its state. Only a screen that is being recreated keeps a draft + * here: a screen that only goes to the background comes back as itself, keeps editing and + * saving, and a draft left here would later be shown, and saved, over that newer content. Such + * a save forgets any draft still kept instead. + * + * @param recreating whether the screen is being recreated (its isChangingConfigurations) + */ + @MainThread + public void keepForRecreation( + boolean recreating, long noteId, @Nullable String title, @Nullable String body) { + if (recreating) put(noteId, title, body); + else discard(); + } + + /** + * Forgets the kept draft. A screen calls this once it holds the note and saves its state + * without being recreated, so no draft older than what it holds is left for a later screen. + */ + @MainThread + public void discard() { + title = null; + body = null; + } + /** The draft kept for {@code noteId}, which is then forgotten; null when there is none. */ @Nullable @MainThread diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java index 0f9f0deb..9cf65be1 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java @@ -80,4 +80,48 @@ public void twoQuickRecreationsWaitForBothPages() { retained.previousPageFinished(); assertThat(shown).containsExactly("now"); } + + /** + * Going to the background saves the screen's state without recreating it: the screen comes back + * as itself and keeps editing, so nothing may be kept for a later recreation to apply. + */ + @Test + public void backgroundThenReturnThenEditThenRotate_doesNotRestoreTheStaleDraft() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + // Home with an unsaved long draft (v1): not a recreation. + retained.keepForRecreation(false, 7, "t1", "v1"); + // Back on the screen, v3 is typed and saved; the rotation has no unsaved draft to keep. + // The next screen finds nothing and shows the saved v3. + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aBackgroundSave_forgetsADraftLeftFromBefore() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "t1", "v1"); + + retained.keepForRecreation(false, 7, "t3", "v3"); + + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aRotationKeepsTheDraftForTheNextScreen() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.keepForRecreation(true, 7, "t3", "v3"); + + RetainedNoteDraft.Draft draft = retained.take(7); + assertThat(draft.title).isEqualTo("t3"); + assertThat(draft.body).isEqualTo("v3"); + } + + @Test + public void discard_forgetsTheDraft() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "t1", "v1"); + + retained.discard(); + + assertThat(retained.take(7)).isNull(); + } } From dc96b5fb2116ae9613bd19c94bb80ec3e111b370 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:03:06 +0200 Subject: [PATCH 38/52] fix(notes): save edits to a note deleted while open as a new note 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. --- .../mynotes/data/database/AppDbHelper.java | 7 +- .../data/database/NoteDeletedException.java | 12 ++ .../mynotes/ui/presenter/NotePresenter.java | 49 ++++- .../NotePresenterDeletedNoteTest.java | 169 ++++++++++++++++++ 4 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/data/database/NoteDeletedException.java create mode 100644 app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java diff --git a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java index 0abbc5d8..b3b9bf30 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java @@ -196,7 +196,12 @@ public Completable deleteNote(ArrayList 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 diff --git a/app/src/main/java/com/pasich/mynotes/data/database/NoteDeletedException.java b/app/src/main/java/com/pasich/mynotes/data/database/NoteDeletedException.java new file mode 100644 index 00000000..eb299b51 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/data/database/NoteDeletedException.java @@ -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"); + } +} diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java index 6714e978..c5978d62 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import com.pasich.mynotes.base.presenter.BasePresenter; import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.NoteDeletedException; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.extendedEditor.models.ParsedNote; import com.pasich.mynotes.extendedEditor.utils.EditorDocument; @@ -15,6 +16,7 @@ import com.pasich.mynotes.utils.enums.SaveState; import com.pasich.mynotes.utils.navigation.NoteExtras; import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; @@ -36,7 +38,8 @@ public class NotePresenter extends BasePresenter // Last successfully saved version of the note private final Note savedNote = new Note().create("", "", new Date().getTime(), ""); - private long idKey; + // Written from the IO thread when a note deleted meanwhile is saved as a new one. + private volatile long idKey; private boolean newNoteKey; private boolean extendedEditor = false; private boolean isSavingInProgress = false; @@ -52,6 +55,8 @@ public class NotePresenter extends BasePresenter // Waiting for the next flushPending write to finish. A flush that replaces one still in flight // takes over its waiters, so none is left behind. private final List flushWaiters = new ArrayList<>(); + // Held while a note deleted meanwhile is saved as a new one, so two saves never both do it. + private final Object recreateLock = new Object(); @Inject public NotePresenter( @@ -210,8 +215,7 @@ public void flushPending(@Nullable Runnable onDone) { updateSaveState(SaveState.SAVING); if (flushSave != null) flushSave.dispose(); flushSave = - getDataManager() - .updateNote(note) + writeNote(note) .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) .subscribe( @@ -313,8 +317,7 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) { getCompositeDisposable() .add( - getDataManager() - .updateNote(note) + writeNote(note) .subscribeOn(getSchedulerProvider().io()) .observeOn(getSchedulerProvider().ui()) .subscribe( @@ -328,6 +331,39 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) { })); } + /** + * Writes the note's content. When the note was deleted meanwhile (a sync applied another + * device's delete while it was open), what is on screen is saved as a new note instead, and the + * editor carries on with that one: the delete stands, and nothing typed is lost. + */ + private Completable writeNote(Note note) { + return Completable.defer(() -> { + int writtenId = note.getId(); + return getDataManager() + .updateNote(note) + .onErrorResumeNext(error -> + error instanceof NoteDeletedException + ? recreateDeletedNote(note, writtenId) + : Completable.error(error)); + }); + } + + private Completable recreateDeletedNote(Note note, int deletedId) { + return Completable.defer(() -> { + synchronized (recreateLock) { + // Another save of this note has already saved it again: write to that one. + if (note.getId() != deletedId) return getDataManager().updateNote(note); + Note revived = note.duplicate(); + revived.setDate(note.getDate()); + long newId = getDataManager().addNote(revived).blockingGet(); + Log.w(TAG, "Note " + deletedId + " was deleted while open; saved as " + newId); + note.setId((int) newId); + idKey = newId; + return Completable.complete(); + } + }); + } + /** * Emergency saving when Activity is destroyed but there is unsaved data. Performed * synchronously to ensure saving. Only NoteActivity (SimpleEditor) @@ -341,8 +377,7 @@ private void performEmergencySave(Note note) { note.setDate(new Date().getTime()); getCompositeDisposable() .add( - getDataManager() - .updateNote(note) + writeNote(note) .subscribeOn(getSchedulerProvider().io()) .subscribe( () -> savedNote.copyFrom(note), diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java new file mode 100644 index 00000000..ad028789 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java @@ -0,0 +1,169 @@ +package com.pasich.mynotes.presenter; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.NoteDeletedException; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.ui.contract.NoteContract; +import com.pasich.mynotes.ui.presenter.NotePresenter; +import com.pasich.mynotes.utils.constants.AutoSave; +import com.pasich.mynotes.utils.enums.SaveState; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +/** + * A note deleted while it is open (a sync applying another device's delete) must not swallow what + * is typed into it: the save fails in the database, and the presenter saves the content as a new + * note and carries on with that one. + */ +public class NotePresenterDeletedNoteTest extends BasePresenterTest { + + private static final int DELETED_ID = 1; + private static final long NEW_ID = 42L; + + @Mock DataManager dataManager; + @Mock NoteContract.view view; + + private final TestScheduler debounceClock = new TestScheduler(); + private final Set existingIds = new HashSet<>(); + private final List written = new ArrayList<>(); + private NotePresenter presenter; + + @Before + public void setUp() { + initMocks(this); + existingIds.add(DELETED_ID); + when(dataManager.updateNote(any())) + .thenAnswer(invocation -> { + Note note = invocation.getArgument(0); + return Completable.fromAction(() -> { + if (!existingIds.contains(note.getId())) { + throw new NoteDeletedException(note.getId()); + } + written.add(note.getId() + ":" + note.getValue()); + }); + }); + when(dataManager.addNote(any())) + .thenAnswer(invocation -> { + Note note = invocation.getArgument(0); + return Single.fromCallable(() -> { + existingIds.add((int) NEW_ID); + written.add("new:" + note.getValue()); + return NEW_ID; + }); + }); + presenter = new NotePresenter(provider(), new CompositeDisposable(), dataManager); + presenter.attachView(view); + + Note stored = new Note().create("Title", "stored text", new Date().getTime(), ""); + stored.setId(DELETED_ID); + stored.setValueJson(""); + stored.setAttachments("[]"); + when(dataManager.getNoteForId(DELETED_ID)).thenReturn(Single.just(stored)); + presenter.setNewNoteKey(false); + presenter.setIdKey(DELETED_ID); + presenter.loadingData(DELETED_ID); + } + + private SchedulerProvider provider() { + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return debounceClock; + } + + @Override + public Scheduler io() { + return Schedulers.trampoline(); + } + }; + } + + private void typeAndWait(String value) { + presenter.simpleNoteChange(null, value, false); + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + } + + @Test + public void anEditToADeletedNote_isSavedAsANewNote() { + existingIds.remove(DELETED_ID); + + typeAndWait("typed after the delete"); + + assertThat(written).containsExactly("new:typed after the delete"); + assertThat(presenter.getIdKey()).isEqualTo(NEW_ID); + assertThat(presenter.getNote().getId()).isEqualTo((int) NEW_ID); + assertThat(presenter.hasUnsavedChanges()).isFalse(); + } + + @Test + public void laterEdits_goToTheNewNote() { + existingIds.remove(DELETED_ID); + typeAndWait("first"); + + typeAndWait("second"); + + assertThat(written).containsExactly("new:first", NEW_ID + ":second").inOrder(); + verify(dataManager, times(1)).addNote(any()); + } + + @Test + public void flushOfADeletedNote_savesItAsANewNote() { + existingIds.remove(DELETED_ID); + presenter.simpleNoteChange(null, "typed before leaving", false); + + presenter.flushPending(); + + assertThat(written).containsExactly("new:typed before leaving"); + } + + @Test + public void otherErrors_areReportedAndCreateNothing() { + when(dataManager.updateNote(any())) + .thenReturn(Completable.error(new IllegalStateException("disk full"))); + + typeAndWait("typed"); + + verify(dataManager, never()).addNote(any()); + ArgumentCaptor states = ArgumentCaptor.forClass(SaveState.class); + verify(view, org.mockito.Mockito.atLeastOnce()).updateSaveStatus(states.capture()); + assertThat(states.getAllValues()).contains(SaveState.ERROR); + assertThat(presenter.hasUnsavedChanges()).isTrue(); + } + + @Test + public void anExistingNote_isUpdatedInPlace() { + typeAndWait("typed"); + + assertThat(written).containsExactly(DELETED_ID + ":typed"); + verify(dataManager, never()).addNote(any()); + assertThat(presenter.getIdKey()).isEqualTo(DELETED_ID); + } +} From 27fe909ff7a838669cf2118f67bde27d61ad4ccb Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:03:07 +0200 Subject: [PATCH 39/52] fix(backup): deliver a late restore/save result only to the screen that 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. --- .../mynotes/ui/contract/BackupContract.java | 15 ++++ .../mynotes/ui/presenter/BackupPresenter.java | 73 +++++++++++++++++-- .../ui/view/activity/BackupActivity.java | 12 +++ .../presenter/BackupPresenterRestoreTest.java | 73 ++++++++++++++++++- 4 files changed, 164 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java index eabdf9c7..3ab6ab5c 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java @@ -1,6 +1,8 @@ package com.pasich.mynotes.ui.contract; import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import com.pasich.mynotes.base.view.BasePresenter; import com.pasich.mynotes.base.view.BaseView; @@ -83,5 +85,18 @@ interface presenter extends BasePresenter { /** Forwards the selected file URI to the view for processing. */ void startProcessSelectedFileOtherApp(Uri fileUri); + + /** + * Adopts the token a recreated screen saved, so it receives results of work it started + * before the recreation. Call before {@code viewIsReady}; null keeps a fresh token. + */ + void restoreScreenToken(@Nullable String token); + + /** The token identifying this screen instance, to keep in its saved state. */ + @NonNull + String getScreenToken(); + + /** The screen is gone for good (not recreated): drop results nobody can receive now. */ + void onScreenFinished(); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java index c0cc0d84..2b7e8996 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java @@ -2,6 +2,7 @@ import android.net.Uri; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.pasich.mynotes.R; import com.pasich.mynotes.base.presenter.BasePresenter; @@ -26,6 +27,7 @@ import java.util.AbstractMap; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -41,16 +43,28 @@ public class BackupPresenter extends BasePresenter * *

A presenter lives exactly as long as its activity, and a rotation in the middle of a * restore used to dispose it part-way, silently. The work now runs to the end and reports to - * whichever presenter is attached at that moment, or to the next one. Main thread only. + * whichever presenter is attached at that moment. A result that finds no screen is kept only + * for the screen that started the work, recognised by its {@link #screenToken} after a + * recreation, and dropped once that screen is finished: it used to wait in a static for the + * life of the process and pop up on any later visit, hours on. Main thread only. */ private static final class BackgroundWork { static boolean restoreRunning; static boolean saveRunning; + @Nullable static String restoreOwner; + @Nullable static String saveOwner; @Nullable static Integer undeliveredRestore; + @Nullable static String undeliveredRestoreOwner; @Nullable static Boolean undeliveredSave; + @Nullable static String undeliveredSaveOwner; @Nullable static BackupPresenter current; } + /** + * Identifies the screen instance across recreations; the activity keeps it in its saved state. + */ + private String screenToken = UUID.randomUUID().toString(); + @Inject public BackupCacheHelper serviceCache; @Inject GoogleKeepImportService importService; @@ -68,6 +82,37 @@ public BackupPresenter( super(schedulerProvider, compositeDisposable, dataManager); } + @Override + public void restoreScreenToken(@Nullable String token) { + if (token != null) { + screenToken = token; + } + } + + @Override + @NonNull + public String getScreenToken() { + return screenToken; + } + + @Override + public void onScreenFinished() { + if (screenToken.equals(BackgroundWork.restoreOwner)) { + BackgroundWork.restoreOwner = null; + } + if (screenToken.equals(BackgroundWork.saveOwner)) { + BackgroundWork.saveOwner = null; + } + if (screenToken.equals(BackgroundWork.undeliveredRestoreOwner)) { + BackgroundWork.undeliveredRestore = null; + BackgroundWork.undeliveredRestoreOwner = null; + } + if (screenToken.equals(BackgroundWork.undeliveredSaveOwner)) { + BackgroundWork.undeliveredSave = null; + BackgroundWork.undeliveredSaveOwner = null; + } + } + @Override public void viewIsReady() { BackgroundWork.current = this; @@ -148,6 +193,7 @@ public void writeFileBackupLocal(Uri mUri) { return; } BackgroundWork.saveRunning = true; + BackgroundWork.saveOwner = screenToken; Disposable ignored = Single.fromCallable(() -> getDataManager().writeBackupLocalFile(serviceCache, mUri)) .subscribeOn(getSchedulerProvider().io()) @@ -178,6 +224,7 @@ public void readFileBackupLocal(Uri mUri) { } getView().showProcessRestoreDialog(); BackgroundWork.restoreRunning = true; + BackgroundWork.restoreOwner = screenToken; Disposable ignored = Completable.timer(3, TimeUnit.SECONDS, getSchedulerProvider().io()) @@ -211,6 +258,7 @@ private void restoreData(JsonBackup jsonBackup) { return; } BackgroundWork.restoreRunning = true; + BackgroundWork.restoreOwner = screenToken; Disposable ignored = restoreCompletable(jsonBackup) .subscribeOn(getSchedulerProvider().io()) @@ -264,11 +312,14 @@ private void runInTransaction(Runnable body) { private static void finishRestore(int infoCode) { BackgroundWork.restoreRunning = false; + String owner = BackgroundWork.restoreOwner; + BackgroundWork.restoreOwner = null; BackupPresenter current = BackgroundWork.current; if (current != null && current.isViewAttached()) { current.deliverRestore(infoCode); - } else { + } else if (owner != null) { BackgroundWork.undeliveredRestore = infoCode; + BackgroundWork.undeliveredRestoreOwner = owner; } } @@ -281,26 +332,36 @@ private void deliverRestore(int infoCode) { private static void finishSave(boolean saved) { BackgroundWork.saveRunning = false; + String owner = BackgroundWork.saveOwner; + BackgroundWork.saveOwner = null; BackupPresenter current = BackgroundWork.current; if (current != null && current.isViewAttached()) { current.getView().createLocalCopyFinish(saved); - } else { + } else if (owner != null) { BackgroundWork.undeliveredSave = saved; + BackgroundWork.undeliveredSaveOwner = owner; } } - /** Hands a screen recreated meanwhile what finished, or is still running, without it. */ + /** + * Shows a running restore's progress, and hands a recreation of the screen that started some + * work what finished without it. Another screen instance never gets someone else's result. + */ private void catchUpOnBackgroundWork() { if (BackgroundWork.restoreRunning) { getView().showProcessRestoreDialog(); - } else if (BackgroundWork.undeliveredRestore != null) { + } else if (BackgroundWork.undeliveredRestore != null + && screenToken.equals(BackgroundWork.undeliveredRestoreOwner)) { int infoCode = BackgroundWork.undeliveredRestore; BackgroundWork.undeliveredRestore = null; + BackgroundWork.undeliveredRestoreOwner = null; deliverRestore(infoCode); } - if (BackgroundWork.undeliveredSave != null) { + if (BackgroundWork.undeliveredSave != null + && screenToken.equals(BackgroundWork.undeliveredSaveOwner)) { boolean saved = BackgroundWork.undeliveredSave; BackgroundWork.undeliveredSave = null; + BackgroundWork.undeliveredSaveOwner = null; getView().createLocalCopyFinish(saved); } } diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java index 708bce8b..a89f7959 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java @@ -169,6 +169,7 @@ public class BackupActivity extends BaseActivity @Nullable private Uri pendingRestorePick; private static final String STATE_PENDING_RESTORE_PICK = "pendingRestorePick"; + private static final String STATE_SCREEN_TOKEN = "backupScreenToken"; private Dialog progressDialog; private RoomSyncStore roomSyncStore; private SyncCoordinatorFactory.Result syncSetup; @@ -215,6 +216,12 @@ public void onCreate(Bundle savedInstanceState) { setupEdgeToEdgeInsets(binding.getRoot()); presenter.attachView(this); + // Ties restores and saves still running to this screen across a recreation, so their + // result reaches it and never a later, unrelated visit to the screen. + presenter.restoreScreenToken( + savedInstanceState == null + ? null + : savedInstanceState.getString(STATE_SCREEN_TOKEN)); presenter.viewIsReady(); binding.setPresenter((BackupPresenter) presenter); @@ -973,6 +980,7 @@ private boolean finishActivity() { @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); + outState.putString(STATE_SCREEN_TOKEN, presenter.getScreenToken()); if (pendingRestorePick != null) { outState.putParcelable(STATE_PENDING_RESTORE_PICK, pendingRestorePick); } @@ -990,6 +998,10 @@ protected void onDestroy() { // refused, which runInBackground already handles, and every delivery re-checks // isFinishing()/isDestroyed() before touching a view. syncExecutor.shutdown(); + if (isFinishing()) { + // Gone for good rather than recreated: nothing can receive its pending results. + presenter.onScreenFinished(); + } if (isDestroyed()) { presenter.detachView(); } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java index c44d6a0b..b01bde9f 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java @@ -1,6 +1,7 @@ package com.pasich.mynotes.presenter; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; @@ -66,9 +67,15 @@ public Scheduler io() { } private BackupPresenter presenter(BackupContract.view view) { + return presenter(view, null); + } + + /** A screen instance; a non-null token makes it the recreation of the one that saved it. */ + private BackupPresenter presenter(BackupContract.view view, String savedToken) { BackupPresenter presenter = new BackupPresenter(schedulers(), new CompositeDisposable(), dataManager); presenter.attachView(view); + presenter.restoreScreenToken(savedToken); presenter.viewIsReady(); return presenter; } @@ -104,18 +111,78 @@ public void restore_reportsToTheScreenThatIsCurrentWhenItEnds() { } @Test - public void restore_finishingWithNoScreen_isReportedToTheNextOne() { + public void restore_finishingWithNoScreen_isReportedToItsRecreation() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view recreated = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.importDataOtherApp(oneKeepNote()); + gone.detachView(); + io.triggerActions(); + + presenter(recreated, token); + + verify(recreated).restoreFinish(CloudErrors.OKAY_RESTORE); + verify(recreated).onRestoreSuccessFlag(); + } + + @Test + public void restore_finishingWithNoScreen_isNotReportedToAnUnrelatedVisit() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view later = mock(BackupContract.view.class); + BackupContract.view recreated = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.importDataOtherApp(oneKeepNote()); + gone.detachView(); + io.triggerActions(); + + // Another instance of the screen does not get it... + BackupPresenter unrelated = presenter(later); + unrelated.detachView(); + verify(later, never()).restoreFinish(anyInt()); + verify(later, never()).onRestoreSuccessFlag(); + // ...and the recreation of the starting screen still does. + presenter(recreated, token); + verify(recreated).restoreFinish(CloudErrors.OKAY_RESTORE); + } + + @Test + public void restore_ofAFinishedScreen_isDroppedNotReportedLater() { BackupContract.view first = mock(BackupContract.view.class); BackupContract.view later = mock(BackupContract.view.class); BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); gone.importDataOtherApp(oneKeepNote()); + gone.onScreenFinished(); gone.detachView(); io.triggerActions(); - presenter(later); + presenter(later, token); + + verify(later, never()).restoreFinish(anyInt()); + verify(later, never()).onRestoreSuccessFlag(); + } + + @Test + public void save_finishingWithNoScreen_reachesOnlyItsRecreation() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view later = mock(BackupContract.view.class); + BackupContract.view recreated = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.writeFileBackupLocal(null); + gone.detachView(); + io.triggerActions(); - verify(later).restoreFinish(CloudErrors.OKAY_RESTORE); + presenter(later).detachView(); + verify(later, never()).createLocalCopyFinish(anyBoolean()); + presenter(recreated, token); + verify(recreated).createLocalCopyFinish(anyBoolean()); } @Test From 56b57bac49cec4b0697e61f14ed907051c2d854f Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:04:05 +0200 Subject: [PATCH 40/52] fix(billing): keep thanked tokens recorded after the purchases query 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. --- .../utils/managers/BillingManager.java | 9 +++++--- .../utils/managers/ThankedPurchases.java | 22 +++++++++++++++---- .../utils/managers/ThankedPurchasesTest.java | 22 ++++++++++++++++++- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java b/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java index 77090441..833cbf76 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java +++ b/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java @@ -113,6 +113,8 @@ private void consumeSettledPurchases(String productId, Runnable onComplete) { QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build(); + // Taken before the query is sent: its answer can only vouch for tokens recorded by then. + Set recordedAtQuery = productId == null ? thankedPurchases.snapshot() : null; billingClient.queryPurchasesAsync( params, @@ -127,14 +129,15 @@ private void consumeSettledPurchases(String productId, Runnable onComplete) { onComplete.run(); return; } - if (productId == null) { + if (recordedAtQuery != null) { // Tokens of purchases that are gone (refunded, revoked) are - // dropped from the record. + // dropped from the record; one thanked after the query was sent + // is not in this answer and is kept. Set owned = new HashSet<>(); for (Purchase purchase : purchasesList) { owned.add(purchase.getPurchaseToken()); } - thankedPurchases.retainOnly(owned); + thankedPurchases.retainOnly(owned, recordedAtQuery); } List settled = new ArrayList<>(); for (Purchase purchase : purchasesList) { diff --git a/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java b/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java index 041f7c02..da5568ab 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java +++ b/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java @@ -33,13 +33,27 @@ void markThanked(String purchaseToken) { if (tokens.add(purchaseToken)) save(tokens); } + /** The tokens recorded right now; pass it to {@link #retainOnly} with a query sent after it. */ + Set snapshot() { + return tokens(); + } + /** - * Keeps only the tokens of purchases that still exist. A purchase refunded or revoked before - * its consume never comes back to be forgotten. + * Drops the tokens of purchases that no longer exist. A purchase refunded or revoked before its + * consume never comes back to be forgotten. + * + *

Only tokens that were already recorded when the purchases query was sent ({@code + * recordedAtQuery}) can be judged by its answer. A purchase thanked while the query was in + * flight is missing from that answer although it exists, and dropping its token got it thanked + * a second time after a failed consume. */ - void retainOnly(Set ownedTokens) { + void retainOnly(Set ownedTokens, Set recordedAtQuery) { Set tokens = tokens(); - if (tokens.retainAll(ownedTokens)) save(tokens); + boolean changed = false; + for (String token : recordedAtQuery) { + if (!ownedTokens.contains(token)) changed |= tokens.remove(token); + } + if (changed) save(tokens); } void forget(String purchaseToken) { diff --git a/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java b/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java index a6afc8a8..f704d1a9 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java @@ -60,11 +60,31 @@ public void retainOnlyDropsTokensOfPurchasesThatAreGone() { thanked.markThanked("owned"); thanked.markThanked("refunded"); - thanked.retainOnly(java.util.Set.of("owned", "never-thanked")); + java.util.Set recordedAtQuery = thanked.snapshot(); + + thanked.retainOnly(java.util.Set.of("owned", "never-thanked"), recordedAtQuery); ThankedPurchases later = new ThankedPurchases(context); assertThat(later.wasThanked("owned")).isTrue(); assertThat(later.wasThanked("refunded")).isFalse(); assertThat(later.wasThanked("never-thanked")).isFalse(); } + + /** + * A purchase thanked while the purchases query was in flight is missing from its answer + * although it exists. If its consume then fails, dropping its token here got it thanked again. + */ + @Test + public void retainOnlyKeepsTokensThankedAfterTheQueryWasSent() { + ThankedPurchases thanked = new ThankedPurchases(context); + thanked.markThanked("refunded"); + java.util.Set recordedAtQuery = thanked.snapshot(); + thanked.markThanked("bought-meanwhile"); + + thanked.retainOnly(java.util.Set.of(), recordedAtQuery); + + ThankedPurchases later = new ThankedPurchases(context); + assertThat(later.wasThanked("bought-meanwhile")).isTrue(); + assertThat(later.wasThanked("refunded")).isFalse(); + } } From 4614daf71f5172385311810f3b76e51f7bad8bbc Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:06:13 +0200 Subject: [PATCH 41/52] perf(sync): re-read snapshot metadata in batches instead of per record 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. --- .../data/database/dao/SyncMetadataDao.java | 6 ++ .../mynotes/data/sync/RoomSyncStore.java | 58 ++++++++++++++++--- .../data/sync/RoomSyncStoreStaleEditTest.java | 35 +++++++++++ .../sync/SyncMutationCoordinatorTest.java | 10 ++++ 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java index d0e10fab..6470b2f8 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java @@ -40,6 +40,12 @@ public interface SyncMetadataDao { + "WHERE recordType = :recordType AND localId IN (:localIds)") List getExistingLocalIds(String recordType, List 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 getByLocalIds(String recordType, List localIds); + @Query( "SELECT * FROM sync_metadata WHERE recordType = :recordType AND stableId = :stableId LIMIT 1") SyncMetadataEntity getByStableId(String recordType, String stableId); diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index af6e3af2..52df755c 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -67,6 +67,10 @@ public final class RoomSyncStore implements SyncStore { private static final String ATTACHMENT_CACHE_DIR = "sync-attachments"; private static final String HASH_CACHE_FILE = "hash-cache.json"; private static final String SHA_256 = "[0-9a-f]{64}"; + + /** Metadata rows re-read per query while building a snapshot; well under SQLite's 999. */ + private static final int SNAPSHOT_METADATA_BATCH = 500; + private final AppDatabase database; private final SharedPreferences preferences; private volatile boolean seeded; @@ -246,14 +250,26 @@ public SnapshotBuildResult buildSnapshot() throws IOException { builtVersionIds.clear(); builtUpdatedAts.clear(); try { - for (SyncMetadataEntity listed : database.syncMetadataDao().getAll()) { - // Re-read right before the content, never after it. The timestamp then predates - // whatever content is read next: an edit saved in between leaves newer content - // under an older timestamp, which the apply's guard sees and skips. Read the other - // way round, an edit landing between the two paired the old content with the new - // timestamp, the guard let the apply through, and the edit was reverted everywhere. - SyncMetadataEntity metadata = - database.syncMetadataDao().get(listed.recordType, listed.localId); + List listedRows = database.syncMetadataDao().getAll(); + List metadataBatch = new ArrayList<>(); + for (int index = 0; index < listedRows.size(); index++) { + if (index % SNAPSHOT_METADATA_BATCH == 0) { + // Re-read before the content, never after it: a batch at a time, right before + // that batch's content is read. Each timestamp then predates the content read + // next: an edit saved in between leaves newer content under an older + // timestamp, which the apply's guard sees and skips. Read the other way round, + // an edit landing between the two paired the old content with the new + // timestamp, the guard let the apply through, and the edit was reverted + // everywhere. One point query per record made a sync thousands of SELECTs. + metadataBatch = + rereadMetadata( + listedRows.subList( + index, + Math.min( + index + SNAPSHOT_METADATA_BATCH, + listedRows.size()))); + } + SyncMetadataEntity metadata = metadataBatch.get(index % SNAPSHOT_METADATA_BATCH); if (metadata == null) { continue; } @@ -304,6 +320,32 @@ public SnapshotBuildResult buildSnapshot() throws IOException { : SnapshotBuildResult.incomplete(snapshot, problems); } + /** + * The current rows for {@code listed}, in the same order, null where a row is gone; one query + * per record type in the batch instead of one per row. + */ + @NonNull + private List rereadMetadata(@NonNull List listed) { + Map> idsByType = new HashMap<>(); + for (SyncMetadataEntity row : listed) { + idsByType + .computeIfAbsent(row.recordType, ignored -> new ArrayList<>()) + .add(row.localId); + } + Map current = new HashMap<>(); + for (Map.Entry> entry : idsByType.entrySet()) { + for (SyncMetadataEntity row : + database.syncMetadataDao().getByLocalIds(entry.getKey(), entry.getValue())) { + current.put(row.recordType + ":" + row.localId, row); + } + } + List result = new ArrayList<>(listed.size()); + for (SyncMetadataEntity row : listed) { + result.add(current.get(row.recordType + ":" + row.localId)); + } + return result; + } + @Override public void applySnapshot( @NonNull SyncSnapshot snapshot, @NonNull List conflicts) diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java index 879af5ab..80626d62 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java @@ -15,6 +15,7 @@ import java.nio.file.Files; import java.time.Instant; import java.util.Collections; +import java.util.UUID; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -124,6 +125,40 @@ public void anUntouchedNoteTakesANewerRemoteVersionFromAClockBehind() throws Exc assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Remote"); } + /** The metadata is re-read in batches; records on every side of a batch boundary are kept. */ + @Test + public void aSnapshotSpanningSeveralMetadataBatchesKeepsEveryRecord() throws Exception { + int count = 1_201; + for (int i = 0; i < count; i++) { + Note note = new Note().create("Note " + i, "Body", 1_000L, ""); + int id = db.noteDao().addNote(note).intValue(); + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, + id, + UUID.nameUUIDFromBytes(("note-" + i).getBytes()).toString(), + 1_000L + i, + null)); + } + RoomSyncStore store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + + SyncSnapshot built = store.readSnapshot(); + + int notes = 0; + for (int i = 0; i < count; i++) { + SyncRecord record = + built.find( + SyncRecord.Type.NOTE, + UUID.nameUUIDFromBytes(("note-" + i).getBytes()).toString()); + assertThat(record).isNotNull(); + assertThat(record.getUpdatedAt().toEpochMilli()).isEqualTo(1_000L + i); + assertThat(record.getPayload().get("b").getAsString()).isEqualTo("Note " + i); + notes++; + } + assertThat(notes).isEqualTo(count); + } + private void saveEdit(int noteId, String title, long at) { Note stored = db.noteDao().getNoteSync(noteId); db.noteDao() diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java index 36cc0bed..791f1bae 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java @@ -745,6 +745,16 @@ public List getExistingLocalIds(String recordType, List localIds) { return existing; } + @Override + public List getByLocalIds(String recordType, List localIds) { + List found = new ArrayList<>(); + for (Long localId : localIds) { + SyncMetadataEntity row = get(recordType, localId); + if (row != null) found.add(row); + } + return found; + } + @Override public SyncMetadataEntity getByStableId(String recordType, String stableId) { for (SyncMetadataEntity value : rows.values()) { From 7b3a82b3d9a0c68a8a0068032595c17da8f592fd Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:07:27 +0200 Subject: [PATCH 42/52] fix(encly): keep records changed since the hand-off when clearing 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. --- .../ui/view/activity/MeetEnclyActivity.java | 46 ++++- .../utils/encly/EnclyMigrationRepository.java | 178 +++++++++++++++--- .../encly/EnclyExportedSnapshotTest.java | 153 +++++++++++++-- 3 files changed, 333 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java index 98c4f2a9..2f0ec94f 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java @@ -66,6 +66,10 @@ public class MeetEnclyActivity extends BaseActivity { private static final String STATE_EXPORTED_TASKS = "encly_exported_tasks"; private static final String STATE_EXPORTED_TAGS = "encly_exported_tags"; private static final String STATE_EXPORTED_CATEGORIES = "encly_exported_categories"; + private static final String STATE_EXPORTED_NOTE_PRINTS = "encly_exported_note_prints"; + private static final String STATE_EXPORTED_TASK_PRINTS = "encly_exported_task_prints"; + private static final String STATE_EXPORTED_TAG_PRINTS = "encly_exported_tag_prints"; + private static final String STATE_EXPORTED_CATEGORY_PRINTS = "encly_exported_category_prints"; @Inject EnclyMigrationRepository repository; @Inject UpdateChecker updateChecker; @@ -467,7 +471,17 @@ private void clearMyNotes() { busy = true; render(); disposables.add( - Completable.fromAction(() -> repository.clearExported(snapshot)) + Completable.fromAction( + () -> { + int kept = repository.clearExported(snapshot); + if (kept > 0) { + Log.i( + TAG, + "kept " + + kept + + " records changed since the hand-off"); + } + }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( @@ -519,6 +533,10 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putLongArray(STATE_EXPORTED_TASKS, exported.tasks); outState.putLongArray(STATE_EXPORTED_TAGS, exported.tags); outState.putLongArray(STATE_EXPORTED_CATEGORIES, exported.categories); + outState.putLongArray(STATE_EXPORTED_NOTE_PRINTS, exported.noteFingerprints); + outState.putLongArray(STATE_EXPORTED_TASK_PRINTS, exported.taskFingerprints); + outState.putLongArray(STATE_EXPORTED_TAG_PRINTS, exported.tagFingerprints); + outState.putLongArray(STATE_EXPORTED_CATEGORY_PRINTS, exported.categoryFingerprints); } } @@ -536,8 +554,30 @@ private void restoreResult(@NonNull Bundle state) { long[] tasks = state.getLongArray(STATE_EXPORTED_TASKS); long[] tags = state.getLongArray(STATE_EXPORTED_TAGS); long[] categories = state.getLongArray(STATE_EXPORTED_CATEGORIES); - if (notes != null && tasks != null && tags != null && categories != null) { - exported = new EnclyMigrationRepository.Exported(notes, tasks, tags, categories); + long[] notePrints = state.getLongArray(STATE_EXPORTED_NOTE_PRINTS); + long[] taskPrints = state.getLongArray(STATE_EXPORTED_TASK_PRINTS); + long[] tagPrints = state.getLongArray(STATE_EXPORTED_TAG_PRINTS); + long[] categoryPrints = state.getLongArray(STATE_EXPORTED_CATEGORY_PRINTS); + // Without the fingerprints nothing could tell a record changed since the hand-off, so the + // clear is not offered at all. + if (notes != null + && tasks != null + && tags != null + && categories != null + && notePrints != null + && taskPrints != null + && tagPrints != null + && categoryPrints != null) { + exported = + new EnclyMigrationRepository.Exported( + notes, + tasks, + tags, + categories, + notePrints, + taskPrints, + tagPrints, + categoryPrints); } if (!state.getBoolean(STATE_HAS_RESULT, false)) return; resultCode = state.getInt(STATE_RESULT_CODE); diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java index 55ac23e2..41476725 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java @@ -18,6 +18,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext; import java.io.File; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -102,69 +106,187 @@ public void clearArchives() { } /** - * Which records a hand-off carried. + * Which records a hand-off carried, and what each held when it was carried. * *

The clear offered afterwards removes exactly these. It used to remove whatever the * database held when the button was pressed, including a note written, or synced in, after the - * archive was made — which never reached Encly. + * archive was made, which never reached Encly. A carried record changed since then (edited + * here, or by a sync) stays too: Encly only has its older content. Each id has the fingerprint + * of its content at the same index. */ public static final class Exported { public final long[] notes; public final long[] tasks; public final long[] tags; public final long[] categories; + public final long[] noteFingerprints; + public final long[] taskFingerprints; + public final long[] tagFingerprints; + public final long[] categoryFingerprints; - public Exported(long[] notes, long[] tasks, long[] tags, long[] categories) { + public Exported( + long[] notes, + long[] tasks, + long[] tags, + long[] categories, + long[] noteFingerprints, + long[] taskFingerprints, + long[] tagFingerprints, + long[] categoryFingerprints) { this.notes = notes; this.tasks = tasks; this.tags = tags; this.categories = categories; + this.noteFingerprints = noteFingerprints; + this.taskFingerprints = taskFingerprints; + this.tagFingerprints = tagFingerprints; + this.categoryFingerprints = categoryFingerprints; } @NonNull public static Exported of(@NonNull Library library) { return new Exported( - ids(library.notes, Note::getId), - ids(library.tasks, Task::getId), - ids(library.tags, Tag::getId), - ids(library.categories, TaskCategory::getId)); + longs(library.notes, Note::getId), + longs(library.tasks, Task::getId), + longs(library.tags, Tag::getId), + longs(library.categories, TaskCategory::getId), + longs(library.notes, EnclyMigrationRepository::fingerprint), + longs(library.tasks, EnclyMigrationRepository::fingerprint), + longs(library.tags, EnclyMigrationRepository::fingerprint), + longs(library.categories, EnclyMigrationRepository::fingerprint)); } - private static long[] ids(List items, ToLongFunction id) { + private static long[] longs(List items, ToLongFunction value) { if (items == null) return new long[0]; long[] out = new long[items.size()]; - for (int i = 0; i < out.length; i++) out[i] = id.applyAsLong(items.get(i)); + for (int i = 0; i < out.length; i++) out[i] = value.applyAsLong(items.get(i)); return out; } } - /** The items of {@code current} whose id is among {@code exported}. */ + /** + * The items of {@code current} whose id is among {@code exported} and whose content still has + * the fingerprint it had then ({@code fingerprints}, at the same index as the id). + */ @NonNull - static List onlyExported( - @Nullable List current, @NonNull long[] exported, @NonNull ToLongFunction id) { + static List onlyExportedUnchanged( + @Nullable List current, + @NonNull long[] exported, + @NonNull long[] fingerprints, + @NonNull ToLongFunction id, + @NonNull ToLongFunction fingerprint) { List out = new ArrayList<>(); - if (current == null || exported.length == 0) return out; - Set wanted = new HashSet<>(); - for (long value : exported) wanted.add(value); + if (current == null || exported.length == 0 || exported.length != fingerprints.length) { + return out; + } + Map wanted = new HashMap<>(); + for (int i = 0; i < exported.length; i++) wanted.put(exported[i], fingerprints[i]); for (T item : current) { - if (wanted.contains(id.applyAsLong(item))) out.add(item); + Long carried = wanted.get(id.applyAsLong(item)); + if (carried != null && carried == fingerprint.applyAsLong(item)) out.add(item); } return out; } + /** A note's content as the hand-off carries it. */ + static long fingerprint(@NonNull Note note) { + return fingerprintOf( + note.getTitle(), + note.getValue(), + note.getValueJson(), + note.getTag(), + note.getAttachments(), + String.valueOf(note.isTrash()), + String.valueOf(note.isPinned())); + } + + /** A task's content as the hand-off carries it; positions are left out, others shift them. */ + static long fingerprint(@NonNull Task task) { + return fingerprintOf( + task.getTitle(), + task.getDescription(), + String.valueOf(task.isDone()), + String.valueOf(task.getCategoryId()), + String.valueOf(task.getCreatedAt())); + } + + static long fingerprint(@NonNull Tag tag) { + return fingerprintOf(tag.getNameTag()); + } + + static long fingerprint(@NonNull TaskCategory category) { + return fingerprintOf(category.getName(), category.getColorHex()); + } + + /** The first 64 bits of a SHA-256 over the parts, each length-prefixed so none can blur. */ + private static long fingerprintOf(String... parts) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + for (String part : parts) { + if (part == null) { + digest.update((byte) 0); + continue; + } + byte[] bytes = part.getBytes(StandardCharsets.UTF_8); + digest.update((byte) 1); + digest.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); + digest.update(bytes); + } + return ByteBuffer.wrap(digest.digest()).getLong(); + } + /** * Deletes the notes, tasks, tags and task categories the hand-off carried, with the notes' * attachment files. Only ever called after Encly confirmed the move and the user confirmed this - * separate step. Anything added since the archive was made stays. + * separate step. Anything added since the archive was made stays, and so does anything changed + * since then: Encly never received that content. + * + * @return how many carried records were kept because they changed since */ @WorkerThread - public void clearExported(@NonNull Exported exported) { + public int clearExported(@NonNull Exported exported) { Library library = load(); - List notes = onlyExported(library.notes, exported.notes, Note::getId); - List tasks = onlyExported(library.tasks, exported.tasks, Task::getId); - List tags = onlyExported(library.tags, exported.tags, Tag::getId); + List notes = + onlyExportedUnchanged( + library.notes, + exported.notes, + exported.noteFingerprints, + Note::getId, + EnclyMigrationRepository::fingerprint); + List tasks = + onlyExportedUnchanged( + library.tasks, + exported.tasks, + exported.taskFingerprints, + Task::getId, + EnclyMigrationRepository::fingerprint); + List tags = + onlyExportedUnchanged( + library.tags, + exported.tags, + exported.tagFingerprints, + Tag::getId, + EnclyMigrationRepository::fingerprint); List categories = - onlyExported(library.categories, exported.categories, TaskCategory::getId); + onlyExportedUnchanged( + library.categories, + exported.categories, + exported.categoryFingerprints, + TaskCategory::getId, + EnclyMigrationRepository::fingerprint); + int changed = + stillPresent(library.notes, exported.notes, Note::getId) + + stillPresent(library.tasks, exported.tasks, Task::getId) + + stillPresent(library.tags, exported.tags, Tag::getId) + + stillPresent(library.categories, exported.categories, TaskCategory::getId) + - notes.size() + - tasks.size() + - tags.size() + - categories.size(); for (Note note : notes) { if (note.getReminderTime() != null) { @@ -189,6 +311,18 @@ public void clearExported(@NonNull Exported exported) { viewState.remove(note.getId()); deleteRecursively(new File(attachments, "note_" + note.getId())); } + return changed; + } + + /** How many of {@code current} have an id among {@code exported}. */ + private static int stillPresent( + @Nullable List current, @NonNull long[] exported, @NonNull ToLongFunction id) { + if (current == null || exported.length == 0) return 0; + Set wanted = new HashSet<>(); + for (long value : exported) wanted.add(value); + int count = 0; + for (T item : current) if (wanted.contains(id.applyAsLong(item))) count++; + return count; } private static String key(String recordType, long localId) { diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java index 564222ff..a956dbfc 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java @@ -2,58 +2,173 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.junit.Test; -/** The clear after a hand-off deletes what the hand-off carried, and nothing added since. */ +/** + * The clear after a hand-off deletes what the hand-off carried, and nothing added or changed since. + */ public class EnclyExportedSnapshotTest { private static Note note(int id) { - Note note = new Note(); + return note(id, "text " + id); + } + + private static Note note(int id, String value) { + Note note = new Note().create("title", value, 0L, ""); note.setId(id); + note.setValueJson(""); + note.setAttachments("[]"); return note; } + private static EnclyMigrationRepository.Library library(List notes) { + return new EnclyMigrationRepository.Library( + notes, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + } + + private static List toDelete(List now, EnclyMigrationRepository.Exported exported) { + return EnclyMigrationRepository.onlyExportedUnchanged( + now, + exported.notes, + exported.noteFingerprints, + Note::getId, + EnclyMigrationRepository::fingerprint); + } + @Test public void keepsOnlyTheRecordsTheHandoffCarried() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of( + library(Arrays.asList(note(1), note(3), note(4)))); List now = Arrays.asList(note(1), note(2), note(3), note(9)); - List toDelete = - EnclyMigrationRepository.onlyExported(now, new long[] {1, 3, 4}, Note::getId); + List deleted = toDelete(now, exported); + + assertEquals(2, deleted.size()); + assertEquals(1, deleted.get(0).getId()); + assertEquals(3, deleted.get(1).getId()); + } + + @Test + public void aNoteChangedSinceTheHandoff_isKept() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of( + library(Arrays.asList(note(1, "carried"), note(2, "carried")))); + // Note 2 was edited, here or by a sync, after the archive was written. + List now = Arrays.asList(note(1, "carried"), note(2, "edited later")); + + List deleted = toDelete(now, exported); + + assertEquals(1, deleted.size()); + assertEquals(1, deleted.get(0).getId()); + } + + @Test + public void aNoteRetitledOrRetaggedSinceTheHandoff_isKept() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of(library(Collections.singletonList(note(1)))); + Note retitled = note(1); + retitled.setTitle("new title"); + Note retagged = note(1); + retagged.setTag("work"); + + assertTrue(toDelete(Collections.singletonList(retitled), exported).isEmpty()); + assertTrue(toDelete(Collections.singletonList(retagged), exported).isEmpty()); + } + + @Test + public void onlyANewEditDate_doesNotKeepTheNote() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of(library(Collections.singletonList(note(1)))); + Note touched = note(1); + touched.setDate(12345L); + touched.setCustomPosition(99L); - assertEquals(2, toDelete.size()); - assertEquals(1, toDelete.get(0).getId()); - assertEquals(3, toDelete.get(1).getId()); + assertEquals(1, toDelete(Collections.singletonList(touched), exported).size()); + } + + @Test + public void changedTasksTagsAndCategories_changeTheirFingerprints() { + Task task = new Task(); + task.setTitle("buy milk"); + long taskPrint = EnclyMigrationRepository.fingerprint(task); + task.setPosition(5); + assertEquals(taskPrint, EnclyMigrationRepository.fingerprint(task)); + task.setDone(true); + assertNotEquals(taskPrint, EnclyMigrationRepository.fingerprint(task)); + + Tag tag = new Tag(); + tag.setNameTag("home"); + long tagPrint = EnclyMigrationRepository.fingerprint(tag); + tag.setNameTag("house"); + assertNotEquals(tagPrint, EnclyMigrationRepository.fingerprint(tag)); + + TaskCategory category = new TaskCategory("errands", "#000000"); + long categoryPrint = EnclyMigrationRepository.fingerprint(category); + category.setName("chores"); + assertNotEquals(categoryPrint, EnclyMigrationRepository.fingerprint(category)); + } + + @Test + public void partsCannotBlurIntoEachOther() { + Note a = note(1, "bc"); + a.setTitle("a"); + Note b = note(1, "c"); + b.setTitle("ab"); + + assertNotEquals( + EnclyMigrationRepository.fingerprint(a), EnclyMigrationRepository.fingerprint(b)); } @Test public void nothingExported_deletesNothing() { + EnclyMigrationRepository.Exported none = + EnclyMigrationRepository.Exported.of(library(new ArrayList<>())); + + assertTrue(toDelete(Arrays.asList(note(1), note(2)), none).isEmpty()); assertTrue( - EnclyMigrationRepository.onlyExported( - Arrays.asList(note(1), note(2)), new long[0], Note::getId) + EnclyMigrationRepository.onlyExportedUnchanged( + null, + new long[] {1}, + new long[] {0}, + Note::getId, + EnclyMigrationRepository::fingerprint) .isEmpty()); - assertTrue( - EnclyMigrationRepository.onlyExported(null, new long[] {1}, Note::getId).isEmpty()); } @Test - public void snapshot_recordsEveryIdOfTheLibrary() { - EnclyMigrationRepository.Library library = - new EnclyMigrationRepository.Library( - Arrays.asList(note(5), note(6)), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>()); + public void fingerprintsThatDoNotMatchTheIds_deleteNothing() { + assertTrue( + EnclyMigrationRepository.onlyExportedUnchanged( + Collections.singletonList(note(1)), + new long[] {1}, + new long[0], + Note::getId, + EnclyMigrationRepository::fingerprint) + .isEmpty()); + } - EnclyMigrationRepository.Exported exported = EnclyMigrationRepository.Exported.of(library); + @Test + public void snapshot_recordsEveryIdOfTheLibraryWithItsFingerprint() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of(library(Arrays.asList(note(5), note(6)))); assertArrayEquals(new long[] {5, 6}, exported.notes); + assertEquals(2, exported.noteFingerprints.length); + assertEquals(EnclyMigrationRepository.fingerprint(note(5)), exported.noteFingerprints[0]); assertEquals(0, exported.tasks.length); + assertEquals(0, exported.taskFingerprints.length); assertEquals(0, exported.tags.length); assertEquals(0, exported.categories.length); } From feb064c2a98255fb117c673a080310f7c000ed63 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:08:28 +0200 Subject: [PATCH 43/52] refactor(sync): invalidate an attachment's candidate listing once per 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. --- .../pasich/mynotes/data/sync/GoogleDriveSyncBackend.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java b/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java index 4be1d27e..a73bb100 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java @@ -1165,13 +1165,14 @@ 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. + // 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); - candidateListings.remove(memoKey(folderId, sha256)); } catch (IOException uploadFailure) { - candidateListings.remove(memoKey(folderId, sha256)); // Attachment identity is its SHA-256. A successful request whose response was lost is // confirmed by discovery, not repeated with an already-consumed stream. if (!mayHaveCommitted(uploadFailure) From d5c85ead398a7b237600fdb638bcfd3901880522 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:09:11 +0200 Subject: [PATCH 44/52] refactor(share): drop the unused deprecated ShareOptionsDialog constructor --- .../mynotes/ui/view/dialogs/ShareOptionsDialog.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java index 51eb2fd1..ae1f2f5e 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java @@ -1,6 +1,5 @@ package com.pasich.mynotes.ui.view.dialogs; -import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.DialogInterface; @@ -78,16 +77,6 @@ public interface ShareEntryPoint { public ShareOptionsDialog() {} - /** - * @deprecated use {@link #newInstance(List, boolean)}; kept for callers not yet moved over. - */ - @Deprecated - @SuppressLint("ValidFragment") - public ShareOptionsDialog(List selectedNotes, boolean isDataExport) { - setArguments(arguments(selectedNotes, false, isDataExport)); - this.notes = selectedNotes == null ? null : new ArrayList<>(selectedNotes); - } - public static ShareOptionsDialog newInstance(@NonNull Note note) { return create(Collections.singletonList(note), true, false); } From 5ac5dd8b2d44673e0fdd57ba2af37361598dceb2 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:23:01 +0200 Subject: [PATCH 45/52] fix(backup): remove the empty file and explain when a second save is 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. --- .../mynotes/ui/contract/BackupContract.java | 6 ++++++ .../mynotes/ui/presenter/BackupPresenter.java | 6 ++++++ .../ui/view/activity/BackupActivity.java | 14 +++++++++++++ app/src/main/res/values-be/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-en-rGB/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-kk/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../presenter/BackupPresenterRestoreTest.java | 21 +++++++++++++++++++ 15 files changed, 58 insertions(+) diff --git a/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java index 3ab6ab5c..839fd358 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java +++ b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java @@ -40,6 +40,12 @@ interface view extends BaseView { /** Called after a local copy write attempt; error is true on failure. */ void createLocalCopyFinish(boolean error); + /** + * Deletes, best effort, the empty file the system picker created for a backup that is not + * going to be written after all. + */ + void discardUnwrittenBackupFile(@Nullable Uri uri); + /** Shows a localised error message identified by errorCode and string. */ void showErrorsText(int errorCode, @StringRes int string); diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java index 2b7e8996..30cede9e 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java @@ -190,6 +190,12 @@ public void saveBackupPresenter(boolean local) { @Override public void writeFileBackupLocal(Uri mUri) { if (BackgroundWork.saveRunning) { + // The picker has already created an empty file at the chosen place. Left there it + // looks like a backup and holds nothing, so remove it and say why nothing was saved. + runOnView(view -> { + view.discardUnwrittenBackupFile(mUri); + view.showErrorsText(0, R.string.save_already_running); + }); return; } BackgroundWork.saveRunning = true; diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java index a89f7959..1b74fb33 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/BackupActivity.java @@ -13,6 +13,7 @@ import android.graphics.Color; import android.net.Uri; import android.os.Bundle; +import android.provider.DocumentsContract; import android.text.Spannable; import android.text.SpannableString; import android.text.style.ForegroundColorSpan; @@ -1131,6 +1132,19 @@ public void onDeviceStorage() { }; } + @Override + public void discardUnwrittenBackupFile(@Nullable Uri uri) { + if (uri == null) { + return; + } + try { + DocumentsContract.deleteDocument(getContentResolver(), uri); + } catch (Exception error) { + // Some providers do not support deleting; the empty file then stays, as before. + Log.w(TAG, "Could not delete the unwritten backup file", error); + } + } + @Override public void showErrorsText(int errorCode, int string) { onInfoSnack(string, null, SnackBarInfo.Error, Snackbar.LENGTH_LONG); diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index cec0fc65..19bba1aa 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -92,6 +92,7 @@ Аднавіць з аблокі Аднаўленне дадзеных \nПасля завяршэння дадзенае акно будзе аўтаматычна зачынена. Іншае аднаўленне яшчэ выконваецца. Выберыце рэзервовую копію зноў, калі яно завершыцца. + Іншая рэзервовая копія яшчэ захоўваецца. Захавайце зноў, калі яна завершыцца. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3f13a084..c8ef0e83 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -85,6 +85,7 @@ Aus Cloud wiederherstellen Daten wiederherstellen \nDieses Fenster wird nach Abschluss automatisch geschlossen. Eine andere Wiederherstellung läuft noch. Wähle die Sicherung erneut aus, sobald sie abgeschlossen ist. + Eine andere Sicherung wird noch gespeichert. Speichere erneut, sobald sie abgeschlossen ist. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index e40756e3..63a599d2 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -106,6 +106,7 @@ Restore from cloud Restoring data\nThis window will close automatically when finished. Another restore is still running. Pick the backup again once it has finished. + Another backup is still being saved. Save again once it has finished. No data to back up Backup created successfully diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 078ff9dd..0f174143 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -95,6 +95,7 @@ Restaurar desde la nube Restaurando datos \nEsta ventana se cerrará automáticamente cuando se complete. Otra restauración sigue en curso. Vuelve a elegir la copia de seguridad cuando termine. + Otra copia de seguridad se sigue guardando. Vuelve a guardar cuando termine. No hay datos para respaldar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d83fcaa2..51476892 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -91,6 +91,7 @@ Restauration à partir du cloud Restaurer les données \nCette fenêtre se fermera automatiquement une fois terminée. Une autre restauration est encore en cours. Sélectionnez à nouveau la sauvegarde une fois celle-ci terminée. + Une autre sauvegarde est encore en cours d\'enregistrement. Réessayez une fois celle-ci terminée. Il n\'y a pas de données à sauvegarder diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 826ba956..33c29bb2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -90,6 +90,7 @@ Ripristina dal cloud Ripristina dati \nQuesta finestra si chiuderà automaticamente al termine. Un altro ripristino è ancora in corso. Seleziona di nuovo il backup al termine. + Un altro backup è ancora in fase di salvataggio. Salva di nuovo al termine. Non ci sono dati di cui eseguire il backup diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 4ec61913..51e25550 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -92,6 +92,7 @@ Бұлттан қалпына келтіру Деректерді қалпына келтіру \nБұл терезе аяқталғаннан кейін автоматты түрде жабылады. Басқа қалпына келтіру әлі орындалуда. Ол аяқталғаннан кейін сақтық көшірмені қайта таңдаңыз. + Басқа сақтық көшірме әлі сақталуда. Ол аяқталғаннан кейін қайта сақтаңыз. Сақтық көшірме жасау үшін деректер жоқ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0c090348..5f345196 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -93,6 +93,7 @@ Przywróć z chmury Przywracanie danych \nTo okno zamknie się automatycznie po zakończeniu. Inne przywracanie wciąż trwa. Wybierz kopię zapasową ponownie, gdy się zakończy. + Inna kopia zapasowa jest wciąż zapisywana. Zapisz ponownie, gdy się zakończy. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 80e9d1e5..4a2370f4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -95,6 +95,7 @@ Восстановить из облака Восстановление данных \nПо завершении это окно закроется автоматически. Другое восстановление ещё выполняется. Выберите резервную копию снова, когда оно завершится. + Другая резервная копия ещё сохраняется. Сохраните снова, когда это завершится. Нет данных, для создания резервной копии diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e981610e..f7febe55 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -92,6 +92,7 @@ Відновити з хмари Відновлення даних \nПісля завершення це вікно буде автоматично закрито. Інше відновлення ще триває. Виберіть резервну копію знову, коли його буде завершено. + Інша резервна копія ще зберігається. Збережіть знову, коли це завершиться. Немає даних для створення резервної копії diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13e90b78..5424be39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,6 +108,7 @@ Restore from Cloud Restoring data \nThis window will close automatically when completed. Another restore is still running. Pick the backup again once it finishes. + Another backup is still being saved. Save again once it finishes. No data to backup diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java index b01bde9f..71fb017e 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java @@ -4,11 +4,13 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.net.Uri; import com.google.gson.Gson; import com.pasich.mynotes.R; import com.pasich.mynotes.base.BasePresenterTest; @@ -185,6 +187,25 @@ public void save_finishingWithNoScreen_reachesOnlyItsRecreation() { verify(recreated).createLocalCopyFinish(anyBoolean()); } + @Test + public void pickingASaveTargetWhileASaveRuns_removesTheEmptyFileAndTellsTheUser() { + BackupContract.view view = mock(BackupContract.view.class); + BackupPresenter presenter = presenter(view); + Uri running = mock(Uri.class); + Uri refused = mock(Uri.class); + presenter.writeFileBackupLocal(running); + + presenter.writeFileBackupLocal(refused); + + verify(view).discardUnwrittenBackupFile(refused); + verify(view).showErrorsText(0, R.string.save_already_running); + verify(view, never()).discardUnwrittenBackupFile(running); + io.triggerActions(); + verify(dataManager).writeBackupLocalFile(any(), eq(running)); + verify(dataManager, never()).writeBackupLocalFile(any(), eq(refused)); + verify(view).createLocalCopyFinish(anyBoolean()); + } + @Test public void failedRestore_isReportedNotSwallowed() { when(dataManager.addTags(anyList())) From 1a5311e7e9b62aba457069dadfb853843086ea90 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:24:42 +0200 Subject: [PATCH 46/52] fix(sync): ignore expired tombstones when deciding whether to publish 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. --- .../pasich/mynotes/data/sync/SyncService.java | 41 ++++++++++++++++++- .../mynotes/data/sync/SyncServiceTest.java | 36 +++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java index 1ae310f1..10d393f6 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java @@ -210,7 +210,11 @@ private SyncState syncExclusively(@NonNull SyncBackend backend) { // the local apply below still receives all of them. SyncSnapshot published = forPublication(merged, remote, alternatives.values()); if (needsPublication( - published, remote, alternatives.keySet(), settledVersionIds, remoteResult)) { + published, + withoutExpiredTombstones(remote, alternatives.values()), + alternatives.keySet(), + settledVersionIds, + remoteResult)) { backend.publish( new SyncPublication( published, @@ -362,6 +366,41 @@ private SyncSnapshot forPublication( return pruned ? new SyncSnapshot(kept) : merged; } + /** + * {@code remote} as this version would have published it: without the expired tombstones {@link + * #forPublication} leaves out. + * + *

Only for deciding whether to publish. An older app version on another device keeps every + * tombstone and puts the expired ones back in each bundle it writes; compared with those, the + * pruned snapshot never matched, and both devices republished the whole bundle on every sync, + * each undoing the other. A remote that differs only by tombstones nobody needs any more + * already says everything this sync would say. A tombstone {@code forPublication} keeps — one + * that is contested, or whose record Drive still holds live — is not a tombstone in {@code + * remote} or is kept here as well. + */ + @NonNull + private SyncSnapshot withoutExpiredTombstones( + @NonNull SyncSnapshot remote, @NonNull Collection alternatives) { + long horizon = clock.millis() - TOMBSTONE_PUBLICATION_HORIZON_MILLIS; + java.util.Set contested = new java.util.HashSet<>(); + for (SyncRecord alternative : alternatives) { + contested.add(alternative.getType().getWireValue() + ":" + alternative.getId()); + } + java.util.List kept = new java.util.ArrayList<>(); + boolean pruned = false; + for (SyncRecord record : remote.getRecords()) { + if (record.isTombstone() + && record.getDeletedAt().toEpochMilli() < horizon + && !contested.contains( + record.getType().getWireValue() + ":" + record.getId())) { + pruned = true; + continue; + } + kept.add(record); + } + return pruned ? new SyncSnapshot(kept) : remote; + } + /** * Re-points every conflict at the version this sync actually applies. * diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java index 3e821967..b01e9afd 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java @@ -196,6 +196,36 @@ public void sync_stopsPublishingADeletionOnceItIsPastTheHorizon() { assertThat(backend.writeSnapshotCalls).isEqualTo(1); } + @Test + public void sync_doesNotRepublishWhenRemoteOnlyAddsBackExpiredTombstones() { + // An older app version on another device keeps every tombstone and puts the expired ones + // back into each bundle it writes. Comparing the pruned snapshot with that bundle made + // both devices republish everything on every sync, forever. + SyncRecord old = tombstone(NOTE_ID, daysBeforeNow(100)); + SyncRecord recent = tombstone(OTHER_NOTE_ID, daysBeforeNow(10)); + FakeStore store = new FakeStore(snapshot(old, recent)); + FakeBackend backend = new FakeBackend(snapshot(old, recent)); + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + assertThat(backend.writeSnapshotCalls).isEqualTo(0); + assertThat(store.appliedSnapshot.getRecords()).containsExactly(old, recent); + } + + @Test + public void sync_stillPublishesWhenRemoteLacksAnUnexpiredTombstone() { + SyncRecord old = tombstone(NOTE_ID, daysBeforeNow(100)); + SyncRecord recent = tombstone(OTHER_NOTE_ID, daysBeforeNow(10)); + FakeStore store = new FakeStore(snapshot(old, recent)); + FakeBackend backend = new FakeBackend(snapshot(old)); + + new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(backend.writeSnapshotCalls).isEqualTo(1); + assertThat(backend.snapshot.getRecords()).containsExactly(recent); + } + @Test public void sync_answersAStaleCopyOfAnOldDeletionWithTheTombstone() { // A device that slept through the horizon never saw the deletion, and once Drive stopped @@ -220,9 +250,11 @@ public void sync_answersAStaleCopyOfAnOldDeletionWithTheTombstone() { assertThat(versions(stale.appliedSnapshot)) .containsExactly(deleted.getCanonicalPayloadHash()); - // Settled everywhere, the tombstone leaves the bundle again and stays out. - assertThat(backend.snapshot.getRecords()).isEmpty(); + // Settled everywhere, nobody republishes the bundle just to drop the expired tombstone: it + // leaves Drive with the next publish that has news, and until then it is harmless. int publishes = backend.writeSnapshotCalls; + new SyncService(stale, new SyncMerger(), CLOCK).sync(backend); + assertThat(backend.writeSnapshotCalls).isEqualTo(publishes); new SyncService(holder, new SyncMerger(), CLOCK).sync(backend); assertThat(backend.writeSnapshotCalls).isEqualTo(publishes); assertThat(versions(holder.appliedSnapshot)) From c42253187f280269534f8d797d3aa73784bc1ba9 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:26:13 +0200 Subject: [PATCH 47/52] perf(sync): persist the attachment digest cache once per attachment phase 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. --- .../mynotes/data/sync/RoomSyncStore.java | 14 ++++++-- .../pasich/mynotes/data/sync/SyncService.java | 28 +++++++++------ .../pasich/mynotes/data/sync/SyncStore.java | 6 ++++ ...omSyncStoreAttachmentVerificationTest.java | 35 +++++++++++++++++++ .../mynotes/data/sync/SyncServiceTest.java | 28 +++++++++++++++ 5 files changed, 97 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java index 52df755c..89f6a8d5 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/RoomSyncStore.java @@ -1770,7 +1770,8 @@ public boolean hasAttachment(@NonNull String sha256) { /** * Answers from the hash cache the snapshot build already uses, so an untouched blob is not read * and hashed again on every sync. The cache keys a digest by path, size and modification time; - * any rewrite of the file is hashed afresh. + * any rewrite of the file is hashed afresh. What it learns is persisted by {@link + * #finishAttachmentVerification}. */ @Override public boolean hasVerifiedAttachment(@NonNull String sha256, @Nullable Long expectedSize) { @@ -1782,11 +1783,18 @@ public boolean hasVerifiedAttachment(@NonNull String sha256, @Nullable Long expe return sha256.equals(hashCache.sha256(file, attachmentHasher::sha256)); } catch (IOException unreadable) { return false; - } finally { - hashCache.flush(); } } + /** + * Persists the digests the verifications learned. Flushing after each one rewrote the whole + * cache file once per attachment, quadratic in the library on a cold cache. + */ + @Override + public void finishAttachmentVerification() { + hashCache.flush(); + } + @Nullable @Override public SnapshotProblem describeMissingAttachment(@NonNull String sha256) { diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java index 10d393f6..3a6b4cfa 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncService.java @@ -193,17 +193,23 @@ private SyncState syncExclusively(@NonNull SyncBackend backend) { // durable unless the loser can later be restored as well, so preflight and pin each // version independently; SyncSnapshot deliberately forbids two versions of one ID. java.util.Set synchronizedHashes = new java.util.HashSet<>(); - synchronizeAttachments(backend, merged, expectedSizes, synchronizedHashes); - for (SyncMergeResult.Conflict conflict : allConflicts) { - // Best effort. The merged snapshot's own blobs are mandatory and were just - // transferred above; these are the extra copies that let a conflict be resolved - // later. An alternative whose bytes have gone from Drive is already beyond - // recovery, and failing here made that one missing blob stop every device from - // syncing anything at all, including the devices that could never resolve it. - // Resolution still verifies before it applies, so a version that cannot be - // materialized simply cannot be chosen. - pinConflictVersionQuietly(backend, conflict.getWinner(), synchronizedHashes); - pinConflictVersionQuietly(backend, conflict.getLoser(), synchronizedHashes); + try { + synchronizeAttachments(backend, merged, expectedSizes, synchronizedHashes); + for (SyncMergeResult.Conflict conflict : allConflicts) { + // Best effort. The merged snapshot's own blobs are mandatory and were just + // transferred above; these are the extra copies that let a conflict be resolved + // later. An alternative whose bytes have gone from Drive is already beyond + // recovery, and failing here made that one missing blob stop every device from + // syncing anything at all, including the devices that could never resolve it. + // Resolution still verifies before it applies, so a version that cannot be + // materialized simply cannot be chosen. + pinConflictVersionQuietly(backend, conflict.getWinner(), synchronizedHashes); + pinConflictVersionQuietly(backend, conflict.getLoser(), synchronizedHashes); + } + } finally { + // Once for the whole phase: persisting the digest cache after every single + // verification rewrote it once per attachment on a cold cache. + store.finishAttachmentVerification(); } // What goes to Drive omits deletions every device has long had the chance to see; diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java index 8f1c4701..b7e2b9e5 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncStore.java @@ -118,6 +118,12 @@ default boolean hasVerifiedAttachment(@NonNull String sha256, @Nullable Long exp } } + /** + * Ends a run of {@link #hasVerifiedAttachment} calls: a store that caches digests persists what + * it learned once here rather than after every verification. + */ + default void finishAttachmentVerification() {} + /** Opens one complete local attachment. The caller closes the returned stream. */ @NonNull InputStream readAttachment(@NonNull String sha256) throws IOException; diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java index c76f09ce..04ecec59 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java @@ -8,6 +8,7 @@ import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.preferences.PreferenceHelper; import java.io.ByteArrayInputStream; +import java.io.File; import java.nio.charset.StandardCharsets; import org.junit.After; import org.junit.Before; @@ -63,6 +64,40 @@ public void anUnchangedBlobIsHashedOnceAcrossRepeatedVerifications() throws Exce assertThat(hashes[0]).isEqualTo(1); } + @Test + public void manyVerificationsWriteTheDigestCacheOnceAtTheEnd() throws Exception { + File cacheFile = + new File( + new File( + RuntimeEnvironment.getApplication().getFilesDir(), + "sync-attachments"), + "hash-cache.json"); + java.util.List written = new java.util.ArrayList<>(); + for (int i = 0; i < 5; i++) { + byte[] bytes = ("photo " + i).getBytes(StandardCharsets.UTF_8); + String hash = Sha256.of(bytes); + store.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + written.add(hash); + } + cacheFile.delete(); + + for (String hash : written) { + assertThat(store.hasVerifiedAttachment(hash, null)).isTrue(); + // Rewriting the whole cache after each one was quadratic on a cold cache. + assertThat(cacheFile.exists()).isFalse(); + } + store.finishAttachmentVerification(); + + assertThat(cacheFile.isFile()).isTrue(); + String persisted = + new String( + java.nio.file.Files.readAllBytes(cacheFile.toPath()), + StandardCharsets.UTF_8); + for (String hash : written) { + assertThat(persisted).contains(hash); + } + } + @Test public void aBlobOfTheWrongSizeOrMissingIsNotVerified() throws Exception { byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java index b01e9afd..d8e9e5ea 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java @@ -175,6 +175,27 @@ public void sync_trustsTheStoresDigestCacheInsteadOfReHashingEveryLocalBlob() th assertThat(store.readAttachmentCalls).isEqualTo(0); } + @Test + public void sync_persistsTheStoresDigestCacheOnceForAllAttachments() throws Exception { + List hashes = new ArrayList<>(); + FakeStore store = new FakeStore(snapshot(note(TEN, "Local with attachments"))); + FakeBackend backend = new FakeBackend(SyncSnapshot.empty()); + for (int i = 0; i < 3; i++) { + byte[] bytes = ("attachment " + i).getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + hashes.add(hash); + store.attachments.put(hash, bytes); + store.verifiedFromCache.add(hash); + backend.attachments.put(hash, bytes); + } + store.attachmentHashes = hashes; + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + assertThat(store.finishAttachmentVerificationCalls).isEqualTo(1); + } + @Test public void sync_stopsPublishingADeletionOnceItIsPastTheHorizon() { // Every tombstone used to go into every bundle for good, and the schema caps a bundle's @@ -726,6 +747,13 @@ public boolean hasAttachment(String sha256) { /** Blobs the store vouches for from its digest cache, as RoomSyncStore does. */ private final java.util.Set verifiedFromCache = new java.util.HashSet<>(); + private int finishAttachmentVerificationCalls; + + @Override + public void finishAttachmentVerification() { + finishAttachmentVerificationCalls++; + } + private int readAttachmentCalls; @Override From e04aee99581472124c8587eeebb7c89660478043 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:28:33 +0200 Subject: [PATCH 48/52] fix(sync): leave sync off when a gone screen refuses the manual sync 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. --- .../mynotes/ui/sync/SyncCoordinator.java | 45 ++++++++++++------- .../mynotes/ui/sync/SyncCoordinatorTest.java | 38 ++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java index c683bc41..041a556b 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java @@ -284,21 +284,31 @@ public void onAuthorized(@NonNull String accessToken) { "Sync was disconnected before it could start")); return; } + // Switched on before the submission so a disconnect queued ahead of the + // sync still wins, and back off when the submission is refused: the + // screen is gone, nothing will run, and leaving it on claimed a sync the + // user was never going to get. The refusal itself is reported. + boolean wasEnabled = preferenceHelper.isSyncEnabled(); preferenceHelper.setSyncEnabled(true); - runOnWorker( - callback, - () -> { - try { - SyncState result = conflictStore.sync(accessToken); - if (result.getStatus() == SyncState.Status.SUCCESS - && preferenceHelper.isBackgroundSyncEnabled()) { - backgroundScheduler.enable(); - } - deliverSuccess(callback, result); - } catch (Exception error) { - deliverError(callback, error); - } - }); + boolean accepted = + runOnWorker( + callback, + () -> { + try { + SyncState result = conflictStore.sync(accessToken); + if (result.getStatus() == SyncState.Status.SUCCESS + && preferenceHelper + .isBackgroundSyncEnabled()) { + backgroundScheduler.enable(); + } + deliverSuccess(callback, result); + } catch (Exception error) { + deliverError(callback, error); + } + }); + if (!accepted && !wasEnabled) { + preferenceHelper.setSyncEnabled(false); + } } @Override @@ -335,13 +345,18 @@ public void resolveConflict( * after that activity finished used to hit a terminated pool and throw {@link * RejectedExecutionException} from whatever thread delivered it. Checking {@code isShutdown()} * first cannot close the race; catching the rejection can. + * + * @return whether the task was accepted; a refusal has already been reported to {@code + * callback}. */ - private void runOnWorker(@NonNull Callback callback, @NonNull Runnable task) { + private boolean runOnWorker(@NonNull Callback callback, @NonNull Runnable task) { try { workerExecutor.execute(task); + return true; } catch (RejectedExecutionException rejected) { Log.w(TAG, "Sync work could not be scheduled; the screen is gone", rejected); deliverError(callback, rejected); + return false; } } diff --git a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java index b639152d..79ef9f34 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java @@ -345,6 +345,44 @@ public void syncNow_queuedBehindADisconnectDoesNotTurnSyncBackOn() { assertThat(preferences.syncEnabled).isFalse(); } + @Test + public void syncNow_refusedByAGoneScreenLeavesSyncDisabledAndReportsIt() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onAuthorized("access-token"); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + task -> { + throw new java.util.concurrent.RejectedExecutionException("shut down"); + }, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + + assertThat(preferences.syncEnabled).isFalse(); + assertThat(store.lastToken).isNull(); + assertThat(callback.error) + .isInstanceOf(java.util.concurrent.RejectedExecutionException.class); + } + @Test public void syncNow_declinedAuthorizationLeavesSyncDisabled() { FakePreferenceHelper preferences = new FakePreferenceHelper(); From 70be95177916f0a5fe93f48f0ca386e57ae35a4b Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:28:34 +0200 Subject: [PATCH 49/52] fix(editor): revive a note deleted while open under its own id 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_. 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. --- .../pasich/mynotes/data/AppDataManager.java | 5 +++ .../mynotes/data/database/AppDbHelper.java | 5 +++ .../data/database/helpers/DbNotesHelper.java | 6 +++ .../data/sync/SyncMutationCoordinator.java | 30 +++++++++++++- .../mynotes/ui/presenter/NotePresenter.java | 41 +++++-------------- .../sync/SyncMutationCoordinatorTest.java | 40 ++++++++++++++++++ .../NotePresenterDeletedNoteTest.java | 39 ++++++++++-------- 7 files changed, 116 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java index 7d40cba0..44d056df 100644 --- a/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java +++ b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java @@ -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 notes) { return dbHelper.moveToNotes(notes); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java index b3b9bf30..aaba13f7 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/AppDbHelper.java @@ -204,6 +204,11 @@ public Completable updateNote(Note note) { }); } + @Override + public Completable reviveNote(Note note) { + return Completable.fromAction(() -> syncMutationCoordinator.reviveNote(note)); + } + @Override public Completable moveToNotes(List notes) { return Completable.fromAction(() -> syncMutationCoordinator.insertNotes(notes)); diff --git a/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java b/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java index 253bf231..7b1fbe40 100644 --- a/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java +++ b/app/src/main/java/com/pasich/mynotes/data/database/helpers/DbNotesHelper.java @@ -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 notes); Completable setTagNote(String nameTag, int idNote); diff --git a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java index 594bc7d9..8491f02c 100644 --- a/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java +++ b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java @@ -431,8 +431,9 @@ private void assignInsertedNoteIds( * open. The content UPDATE then matches no row, and touching the metadata anyway cleared the * tombstone, leaving a live record with nothing behind it — the edit lost silently, and the * remote deletion skipped forever because the record now looked newer. Re-inserting the note - * instead is not an option either: the deletion already removed its history and attachments. So - * nothing is written and the caller is told. + * silently is not right either: the deletion already removed its history and attachments. So + * nothing is written and the caller is told; it can put the note back deliberately with {@link + * #reviveNote}. * * @return false when the note no longer exists and nothing was stored. */ @@ -456,6 +457,31 @@ public boolean updateNoteContent(@NonNull Note note) { }); } + /** + * Stores the editor's content for a note that may have been deleted while it was open, putting + * the note back under its own id when it was. + * + *

Keeping the id is what keeps everything that names the note valid: the editor's screen + * state and draft, and the {@code note_} attachment folder its blocks point into. Touching + * the metadata clears the tombstone and stamps the record newer than the deletion (the touch is + * monotonic past it), so the next sync publishes the note again under its old stable id and the + * edit outranks the deletion everywhere. Files the deletion removed stay gone; the blocks that + * pointed at them simply no longer resolve. + */ + public void reviveNote(@NonNull Note note) { + if (note.getId() <= 0) throw new IllegalArgumentException("A note to revive needs its id"); + transactionExecutor.run(() -> { + if (noteDao.getNoteSync(note.getId()) != null) { + // Someone else (an earlier save of the same editor) already put it back. + updateNoteContent(note); + return null; + } + noteDao.addNote(note); + touchRecord(SyncMetadata.RECORD_TYPE_NOTE, note.getId(), timeProvider.now()); + return null; + }); + } + /** * Puts one of a note's earlier versions back as its current content. * diff --git a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java index c5978d62..ab92ed7e 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java +++ b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java @@ -38,7 +38,6 @@ public class NotePresenter extends BasePresenter // Last successfully saved version of the note private final Note savedNote = new Note().create("", "", new Date().getTime(), ""); - // Written from the IO thread when a note deleted meanwhile is saved as a new one. private volatile long idKey; private boolean newNoteKey; private boolean extendedEditor = false; @@ -55,8 +54,6 @@ public class NotePresenter extends BasePresenter // Waiting for the next flushPending write to finish. A flush that replaces one still in flight // takes over its waiters, so none is left behind. private final List flushWaiters = new ArrayList<>(); - // Held while a note deleted meanwhile is saved as a new one, so two saves never both do it. - private final Object recreateLock = new Object(); @Inject public NotePresenter( @@ -333,35 +330,19 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) { /** * Writes the note's content. When the note was deleted meanwhile (a sync applied another - * device's delete while it was open), what is on screen is saved as a new note instead, and the - * editor carries on with that one: the delete stands, and nothing typed is lost. + * device's delete while it was open), it is put back under its own id with what is on screen: + * nothing typed is lost, and the id the screen, its saved state, its draft and the note's + * attachment folder all go by stays valid. The edit is newer than the deletion, so it is what + * the next sync keeps. */ private Completable writeNote(Note note) { - return Completable.defer(() -> { - int writtenId = note.getId(); - return getDataManager() - .updateNote(note) - .onErrorResumeNext(error -> - error instanceof NoteDeletedException - ? recreateDeletedNote(note, writtenId) - : Completable.error(error)); - }); - } - - private Completable recreateDeletedNote(Note note, int deletedId) { - return Completable.defer(() -> { - synchronized (recreateLock) { - // Another save of this note has already saved it again: write to that one. - if (note.getId() != deletedId) return getDataManager().updateNote(note); - Note revived = note.duplicate(); - revived.setDate(note.getDate()); - long newId = getDataManager().addNote(revived).blockingGet(); - Log.w(TAG, "Note " + deletedId + " was deleted while open; saved as " + newId); - note.setId((int) newId); - idKey = newId; - return Completable.complete(); - } - }); + return getDataManager() + .updateNote(note) + .onErrorResumeNext(error -> { + if (!(error instanceof NoteDeletedException)) return Completable.error(error); + Log.w(TAG, "Note " + note.getId() + " was deleted while open; reviving it"); + return getDataManager().reviveNote(note); + }); } /** diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java index 791f1bae..6dc88829 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java @@ -241,6 +241,46 @@ public void updateNoteContent_leavesTheTombstoneOfANoteDeletedMeanwhile() { assertThat(metadata.deletedAt).isEqualTo(900L); } + @Test + public void reviveNote_reinsertsUnderItsIdAndOutranksTheTombstone() { + // The deletion came from another device whose clock is ahead: the tombstone is stamped + // later than this device's now. The revived record must still be the newer one, under the + // stable id the other devices know, or the next sync would delete the edit again. + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 7L, "seed-stable", 5_000L, 5_000L)); + when(noteDao.getNoteSync(7)).thenReturn(null); + Note note = new Note().create("Late edit", "Body", 10L, ""); + note.setId(7); + + coordinator.reviveNote(note); + + verify(noteDao).addNote(note); + assertThat(note.getId()).isEqualTo(7); + SyncMetadataEntity metadata = syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 7L); + assertThat(metadata.stableId).isEqualTo("seed-stable"); + assertThat(metadata.updatedAt).isGreaterThan(5_000L); + assertThat(metadata.deletedAt).isNull(); + } + + @Test + public void reviveNote_ofANoteThatExists_updatesItInPlace() { + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 7L, "seed-stable", 80L, null)); + Note note = new Note().create("Edit", "Body", 10L, ""); + note.setId(7); + note.setValueJson("{}"); + note.setAttachments("[]"); + + coordinator.reviveNote(note); + + verify(noteDao, never()).addNote(org.mockito.ArgumentMatchers.any()); + verify(noteDao) + .updateNoteContent( + eq(7), eq("Edit"), eq("Body"), eq("{}"), eq(10L), eq(""), eq("[]")); + } + @Test public void deleteNote_keepsTombstoneAndMarksDeletionTimestamp() { syncMetadataDao.insertIfAbsent( diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java index ad028789..0370974d 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java @@ -35,13 +35,13 @@ /** * A note deleted while it is open (a sync applying another device's delete) must not swallow what - * is typed into it: the save fails in the database, and the presenter saves the content as a new - * note and carries on with that one. + * is typed into it: the save fails in the database, and the presenter puts the note back under its + * own id. The id has to stay: the screen's intent, saved state and draft, and the note's attachment + * folder all go by it. */ public class NotePresenterDeletedNoteTest extends BasePresenterTest { private static final int DELETED_ID = 1; - private static final long NEW_ID = 42L; @Mock DataManager dataManager; @Mock NoteContract.view view; @@ -65,13 +65,12 @@ public void setUp() { written.add(note.getId() + ":" + note.getValue()); }); }); - when(dataManager.addNote(any())) + when(dataManager.reviveNote(any())) .thenAnswer(invocation -> { Note note = invocation.getArgument(0); - return Single.fromCallable(() -> { - existingIds.add((int) NEW_ID); - written.add("new:" + note.getValue()); - return NEW_ID; + return Completable.fromAction(() -> { + existingIds.add(note.getId()); + written.add("revived " + note.getId() + ":" + note.getValue()); }); }); presenter = new NotePresenter(provider(), new CompositeDisposable(), dataManager); @@ -112,36 +111,39 @@ private void typeAndWait(String value) { } @Test - public void anEditToADeletedNote_isSavedAsANewNote() { + public void anEditToADeletedNote_revivesItUnderItsOwnId() { existingIds.remove(DELETED_ID); typeAndWait("typed after the delete"); - assertThat(written).containsExactly("new:typed after the delete"); - assertThat(presenter.getIdKey()).isEqualTo(NEW_ID); - assertThat(presenter.getNote().getId()).isEqualTo((int) NEW_ID); + assertThat(written).containsExactly("revived " + DELETED_ID + ":typed after the delete"); + assertThat(presenter.getIdKey()).isEqualTo(DELETED_ID); + assertThat(presenter.getNote().getId()).isEqualTo(DELETED_ID); assertThat(presenter.hasUnsavedChanges()).isFalse(); + verify(dataManager, never()).addNote(any()); } @Test - public void laterEdits_goToTheNewNote() { + public void laterEdits_updateTheRevivedNote() { existingIds.remove(DELETED_ID); typeAndWait("first"); typeAndWait("second"); - assertThat(written).containsExactly("new:first", NEW_ID + ":second").inOrder(); - verify(dataManager, times(1)).addNote(any()); + assertThat(written) + .containsExactly("revived " + DELETED_ID + ":first", DELETED_ID + ":second") + .inOrder(); + verify(dataManager, times(1)).reviveNote(any()); } @Test - public void flushOfADeletedNote_savesItAsANewNote() { + public void flushOfADeletedNote_revivesIt() { existingIds.remove(DELETED_ID); presenter.simpleNoteChange(null, "typed before leaving", false); presenter.flushPending(); - assertThat(written).containsExactly("new:typed before leaving"); + assertThat(written).containsExactly("revived " + DELETED_ID + ":typed before leaving"); } @Test @@ -151,6 +153,7 @@ public void otherErrors_areReportedAndCreateNothing() { typeAndWait("typed"); + verify(dataManager, never()).reviveNote(any()); verify(dataManager, never()).addNote(any()); ArgumentCaptor states = ArgumentCaptor.forClass(SaveState.class); verify(view, org.mockito.Mockito.atLeastOnce()).updateSaveStatus(states.capture()); @@ -163,7 +166,7 @@ public void anExistingNote_isUpdatedInPlace() { typeAndWait("typed"); assertThat(written).containsExactly(DELETED_ID + ":typed"); - verify(dataManager, never()).addNote(any()); + verify(dataManager, never()).reviveNote(any()); assertThat(presenter.getIdKey()).isEqualTo(DELETED_ID); } } From 47d0e39deb32d17af99493f84d40250986b948dc Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:28:36 +0200 Subject: [PATCH 50/52] fix(settings): revert a refused non-touch volume change at once 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. --- .../settings/MediaSettingsFragment.java | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java b/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java index 8923d3bc..71b36599 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/fragment/settings/MediaSettingsFragment.java @@ -121,6 +121,9 @@ private void initListeners() { binding.volumeSeekBar.setOnSeekBarChangeListener( new SeekBar.OnSeekBarChangeListener() { + /** A touch drag is in progress. */ + private boolean tracking; + /** The system refused a change during this drag. */ private boolean refused; @@ -133,26 +136,39 @@ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { } catch (SecurityException e) { // With Do Not Disturb on, the system refuses volume changes that // would toggle it unless the app has notification policy access. - // The rest of the drag is not tried again (every step would throw - // and log), and the slider goes back to the volume in effect. Log.w(TAG, "Notification volume change refused: " + e.getMessage()); - refused = true; + if (tracking) { + // The rest of the drag is not tried again (every step would + // throw and log); the slider goes back when the drag ends. + refused = true; + } else { + // A keyboard, D-pad or accessibility change: no drag will end + // to put the slider back, so it goes back to the volume in + // effect now, and the next such change is tried afresh. + showVolumeInEffect(seekBar); + } } } @Override public void onStartTrackingTouch(SeekBar seekBar) { + tracking = true; refused = false; } @Override public void onStopTrackingTouch(SeekBar seekBar) { - if (refused && audioManager != null) { - seekBar.setProgress( - audioManager.getStreamVolume(AudioManager.STREAM_NOTIFICATION)); - } + if (refused) showVolumeInEffect(seekBar); + tracking = false; refused = false; } + + private void showVolumeInEffect(SeekBar seekBar) { + if (audioManager == null) return; + // Not from the user, so onProgressChanged ignores it. + seekBar.setProgress( + audioManager.getStreamVolume(AudioManager.STREAM_NOTIFICATION)); + } }); } From 009245410eb97deb4d0d2cb4feb541139460f596 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:28:39 +0200 Subject: [PATCH 51/52] fix(encly): keep the hand-off snapshot on disk, not in saved state 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. --- .../ui/view/activity/MeetEnclyActivity.java | 124 +++++++------ .../utils/encly/EnclyMigrationRepository.java | 28 +++ .../utils/encly/ExportedSnapshotStore.java | 170 ++++++++++++++++++ .../encly/ExportedSnapshotStoreTest.java | 105 +++++++++++ 4 files changed, 373 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java create mode 100644 app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java diff --git a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java index 2f0ec94f..ee516c2d 100644 --- a/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java +++ b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java @@ -36,6 +36,7 @@ import com.pasich.mynotes.utils.encly.HandoffResult; import dagger.hilt.android.AndroidEntryPoint; import io.reactivex.Completable; +import io.reactivex.Maybe; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; @@ -62,14 +63,7 @@ public class MeetEnclyActivity extends BaseActivity { private static final String STATE_SKIPPED = "encly_skipped"; private static final String STATE_REASON = "encly_reason"; private static final String STATE_CLEARED = "encly_cleared"; - private static final String STATE_EXPORTED_NOTES = "encly_exported_notes"; - private static final String STATE_EXPORTED_TASKS = "encly_exported_tasks"; - private static final String STATE_EXPORTED_TAGS = "encly_exported_tags"; - private static final String STATE_EXPORTED_CATEGORIES = "encly_exported_categories"; - private static final String STATE_EXPORTED_NOTE_PRINTS = "encly_exported_note_prints"; - private static final String STATE_EXPORTED_TASK_PRINTS = "encly_exported_task_prints"; - private static final String STATE_EXPORTED_TAG_PRINTS = "encly_exported_tag_prints"; - private static final String STATE_EXPORTED_CATEGORY_PRINTS = "encly_exported_category_prints"; + private static final String STATE_EXPORTED_TOKEN = "encly_exported_token"; @Inject EnclyMigrationRepository repository; @Inject UpdateChecker updateChecker; @@ -91,6 +85,12 @@ public class MeetEnclyActivity extends BaseActivity { /** What the last hand-off carried; the only records a clear may delete. */ @Nullable private EnclyMigrationRepository.Exported exported; + /** + * Names the copy of {@link #exported} kept on disk. Only this goes into the saved state: the + * snapshot itself is an id and a fingerprint per record, too big for it with a large library. + */ + @Nullable private String exportedToken; + private final ActivityResultLauncher enclyLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), this::onEnclyResult); @@ -112,6 +112,7 @@ public void onCreate(Bundle savedInstanceState) { if (savedInstanceState == null) { // A hand-off file left behind by a crash or a killed process is plain text: sweep it. clearArchivesInBackground(); + forgetExportedInBackground(System.currentTimeMillis()); } else { restoreResult(savedInstanceState); } @@ -354,16 +355,21 @@ private void startHandoff(@NonNull String activityClassName) { () -> { repository.clearArchives(); EnclyMigrationRepository.Library library = repository.load(); - return new android.util.Pair<>( - repository.writeArchive(library), - EnclyMigrationRepository.Exported.of(library)); + EnclyMigrationRepository.Exported snapshot = + EnclyMigrationRepository.Exported.of(library); + // Saved before Encly is opened, so the snapshot survives + // this process being killed while Encly is in front. + String token = repository.saveExported(snapshot); + return new PreparedHandoff( + repository.writeArchive(library), snapshot, token); }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( prepared -> { - exported = prepared.second; - launchEncly(prepared.first, activityClassName); + exported = prepared.exported; + exportedToken = prepared.token; + launchEncly(prepared.archive, activityClassName); }, error -> { Log.e(TAG, "prepare hand-off", error); @@ -488,6 +494,10 @@ private void clearMyNotes() { () -> { busy = false; cleared = true; + // Nothing is left to clear: the snapshot has done its job. + exported = null; + exportedToken = null; + forgetExportedInBackground(Long.MAX_VALUE); loadSummary(); render(); onInfoSnack( @@ -515,6 +525,29 @@ private void clearArchivesInBackground() { .subscribe(() -> {}, error -> Log.e(TAG, "clear hand-off files", error))); } + private void forgetExportedInBackground(long requestedAt) { + disposables.add( + Completable.fromAction(() -> repository.forgetExported(requestedAt)) + .subscribeOn(Schedulers.io()) + .subscribe(() -> {}, error -> Log.e(TAG, "forget hand-off", error))); + } + + /** Reads back what the hand-off before a recreation carried. */ + private void loadExportedInBackground(@NonNull String token) { + disposables.add( + Maybe.fromCallable(() -> repository.loadExported(token)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + snapshot -> { + // A hand-off started since then carries its own snapshot. + if (!token.equals(exportedToken)) return; + exported = snapshot; + render(); + }, + error -> Log.e(TAG, "load hand-off", error))); + } + @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); @@ -526,18 +559,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(STATE_SKIPPED, resultCounts[3]); outState.putString(STATE_REASON, resultReason); outState.putBoolean(STATE_CLEARED, cleared); - if (exported != null) { - // Saved before Encly is opened, so the snapshot survives this process being killed - // while Encly is in front. - outState.putLongArray(STATE_EXPORTED_NOTES, exported.notes); - outState.putLongArray(STATE_EXPORTED_TASKS, exported.tasks); - outState.putLongArray(STATE_EXPORTED_TAGS, exported.tags); - outState.putLongArray(STATE_EXPORTED_CATEGORIES, exported.categories); - outState.putLongArray(STATE_EXPORTED_NOTE_PRINTS, exported.noteFingerprints); - outState.putLongArray(STATE_EXPORTED_TASK_PRINTS, exported.taskFingerprints); - outState.putLongArray(STATE_EXPORTED_TAG_PRINTS, exported.tagFingerprints); - outState.putLongArray(STATE_EXPORTED_CATEGORY_PRINTS, exported.categoryFingerprints); - } + outState.putString(STATE_EXPORTED_TOKEN, exportedToken); } /** @@ -550,35 +572,9 @@ private boolean canClear() { private void restoreResult(@NonNull Bundle state) { cleared = state.getBoolean(STATE_CLEARED, false); - long[] notes = state.getLongArray(STATE_EXPORTED_NOTES); - long[] tasks = state.getLongArray(STATE_EXPORTED_TASKS); - long[] tags = state.getLongArray(STATE_EXPORTED_TAGS); - long[] categories = state.getLongArray(STATE_EXPORTED_CATEGORIES); - long[] notePrints = state.getLongArray(STATE_EXPORTED_NOTE_PRINTS); - long[] taskPrints = state.getLongArray(STATE_EXPORTED_TASK_PRINTS); - long[] tagPrints = state.getLongArray(STATE_EXPORTED_TAG_PRINTS); - long[] categoryPrints = state.getLongArray(STATE_EXPORTED_CATEGORY_PRINTS); - // Without the fingerprints nothing could tell a record changed since the hand-off, so the - // clear is not offered at all. - if (notes != null - && tasks != null - && tags != null - && categories != null - && notePrints != null - && taskPrints != null - && tagPrints != null - && categoryPrints != null) { - exported = - new EnclyMigrationRepository.Exported( - notes, - tasks, - tags, - categories, - notePrints, - taskPrints, - tagPrints, - categoryPrints); - } + // Read back off the main thread; until then no clear is offered. + exportedToken = state.getString(STATE_EXPORTED_TOKEN); + if (exportedToken != null) loadExportedInBackground(exportedToken); if (!state.getBoolean(STATE_HAS_RESULT, false)) return; resultCode = state.getInt(STATE_RESULT_CODE); resultCounts = @@ -616,6 +612,26 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override protected void onDestroy() { disposables.clear(); + if (isFinishing()) { + // The screen is gone for good, and its snapshot with it. + Schedulers.io().scheduleDirect(() -> repository.forgetExported(Long.MAX_VALUE)); + } super.onDestroy(); } + + /** What preparing a hand-off produced. */ + private static final class PreparedHandoff { + final File archive; + final EnclyMigrationRepository.Exported exported; + final String token; + + PreparedHandoff( + @NonNull File archive, + @NonNull EnclyMigrationRepository.Exported exported, + @NonNull String token) { + this.archive = archive; + this.exported = exported; + this.token = token; + } + } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java index 41476725..c15ae3fb 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java @@ -105,6 +105,34 @@ public void clearArchives() { HandoffArchive.clear(context.getCacheDir()); } + /** + * Keeps what a hand-off carried until Encly answers; see {@link ExportedSnapshotStore}. + * + * @return the token that reads it back. + */ + @WorkerThread + @NonNull + public String saveExported(@NonNull Exported exported) throws IOException { + return ExportedSnapshotStore.save(exportedDir(), exported); + } + + /** What the hand-off saved under {@code token} carried, or null when it is gone. */ + @WorkerThread + @Nullable + public Exported loadExported(@Nullable String token) { + return ExportedSnapshotStore.load(exportedDir(), token); + } + + /** Deletes the hand-off snapshots saved before {@code requestedAt}. */ + @WorkerThread + public void forgetExported(long requestedAt) { + ExportedSnapshotStore.sweep(exportedDir(), requestedAt); + } + + private File exportedDir() { + return new File(context.getNoBackupFilesDir(), "encly-handoff"); + } + /** * Which records a hand-off carried, and what each held when it was carried. * diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java b/app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java new file mode 100644 index 00000000..21d0d7a7 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java @@ -0,0 +1,170 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.SecureRandom; + +/** + * Keeps the record of what a hand-off carried ({@link EnclyMigrationRepository.Exported}) on disk + * while the Meet Encly screen waits for Encly's answer. + * + *

It has to outlive the screen, and the process, while Encly is in front. It used to go into the + * saved instance state, but that is an id and a fingerprint for every record in the library: a big + * library overflowed the Binder transaction and crashed the app on the way to the background. Only + * the token naming the file goes into the saved state now. + * + *

A token is the time it was made and a random part. A sweep deletes only files made before it + * was asked for, so a sweep that runs late cannot take a snapshot saved after it. + */ +public final class ExportedSnapshotStore { + + private static final String PREFIX = "exported-"; + private static final String SUFFIX = ".bin"; + private static final int FORMAT = 1; + private static final SecureRandom RANDOM = new SecureRandom(); + + private ExportedSnapshotStore() {} + + /** + * Writes the snapshot into {@code dir}, replacing any saved before it. + * + * @return the token that reads it back. + */ + @NonNull + public static synchronized String save( + @NonNull File dir, @NonNull EnclyMigrationRepository.Exported exported) + throws IOException { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Cannot create " + dir); + } + String token = System.currentTimeMillis() + "-" + Long.toHexString(RANDOM.nextLong() >>> 1); + File file = fileFor(dir, token); + File temp = new File(dir, PREFIX + token + ".tmp"); + try (DataOutputStream out = + new DataOutputStream(new BufferedOutputStream(new FileOutputStream(temp)))) { + out.writeInt(FORMAT); + writeLongs(out, exported.notes); + writeLongs(out, exported.tasks); + writeLongs(out, exported.tags); + writeLongs(out, exported.categories); + writeLongs(out, exported.noteFingerprints); + writeLongs(out, exported.taskFingerprints); + writeLongs(out, exported.tagFingerprints); + writeLongs(out, exported.categoryFingerprints); + } catch (IOException e) { + temp.delete(); + throw e; + } + if (!temp.renameTo(file)) { + temp.delete(); + throw new IOException("Cannot store " + file); + } + File[] files = dir.listFiles(); + if (files != null) { + for (File other : files) { + if (other.getName().startsWith(PREFIX) && !other.equals(file)) other.delete(); + } + } + return token; + } + + /** The snapshot saved under {@code token}, or null when it is gone or unreadable. */ + @Nullable + public static synchronized EnclyMigrationRepository.Exported load( + @NonNull File dir, @Nullable String token) { + if (token == null || !isToken(token)) return null; + File file = fileFor(dir, token); + if (!file.isFile()) return null; + try (DataInputStream in = + new DataInputStream(new BufferedInputStream(new FileInputStream(file)))) { + if (in.readInt() != FORMAT) return null; + long max = file.length() / Long.BYTES; + long[] notes = readLongs(in, max); + long[] tasks = readLongs(in, max); + long[] tags = readLongs(in, max); + long[] categories = readLongs(in, max); + long[] notePrints = readLongs(in, max); + long[] taskPrints = readLongs(in, max); + long[] tagPrints = readLongs(in, max); + long[] categoryPrints = readLongs(in, max); + // Without a fingerprint per id nothing could tell a record changed since the + // hand-off, so a damaged file offers no clear at all. + if (notes.length != notePrints.length + || tasks.length != taskPrints.length + || tags.length != tagPrints.length + || categories.length != categoryPrints.length) { + return null; + } + return new EnclyMigrationRepository.Exported( + notes, + tasks, + tags, + categories, + notePrints, + taskPrints, + tagPrints, + categoryPrints); + } catch (IOException | RuntimeException e) { + return null; + } + } + + /** + * Deletes the snapshots saved before {@code requestedAt}, in milliseconds since the epoch; pass + * {@link Long#MAX_VALUE} to delete every one. + * + * @return how many were deleted. + */ + public static synchronized int sweep(@NonNull File dir, long requestedAt) { + File[] files = dir.listFiles(); + if (files == null) return 0; + int deleted = 0; + for (File file : files) { + String name = file.getName(); + if (!name.startsWith(PREFIX)) continue; + if (madeAt(name) < requestedAt && file.delete()) deleted++; + } + return deleted; + } + + private static File fileFor(File dir, String token) { + return new File(dir, PREFIX + token + SUFFIX); + } + + private static boolean isToken(String token) { + return token.matches("\\d+-[0-9a-f]+"); + } + + /** When the file was made, from its name; a name that does not say counts as long ago. */ + private static long madeAt(String name) { + int start = PREFIX.length(); + int end = name.indexOf('-', start); + if (end < 0) return Long.MIN_VALUE; + try { + return Long.parseLong(name.substring(start, end)); + } catch (NumberFormatException e) { + return Long.MIN_VALUE; + } + } + + private static void writeLongs(DataOutputStream out, long[] values) throws IOException { + out.writeInt(values.length); + for (long value : values) out.writeLong(value); + } + + private static long[] readLongs(DataInputStream in, long max) throws IOException { + int length = in.readInt(); + if (length < 0 || length > max) throw new IOException("Bad length " + length); + long[] values = new long[length]; + for (int i = 0; i < length; i++) values[i] = in.readLong(); + return values; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java new file mode 100644 index 00000000..81843373 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java @@ -0,0 +1,105 @@ +package com.pasich.mynotes.utils.encly; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * What a hand-off carried is kept on disk instead of in the saved instance state, which a large + * library overflowed; it has to come back exactly, and only under its own token. + */ +public class ExportedSnapshotStoreTest { + + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + private static EnclyMigrationRepository.Exported snapshot(int records) { + long[] ids = new long[records]; + long[] prints = new long[records]; + for (int i = 0; i < records; i++) { + ids[i] = i + 1; + prints[i] = 31L * i - 7L; + } + return new EnclyMigrationRepository.Exported( + ids, + new long[] {5}, + new long[0], + new long[] {2, 3}, + prints, + new long[] {-1}, + new long[0], + new long[] {Long.MAX_VALUE, Long.MIN_VALUE}); + } + + @Test + public void aSavedSnapshot_readsBackExactly() throws IOException { + File dir = new File(folder.getRoot(), "encly"); + EnclyMigrationRepository.Exported saved = snapshot(50_000); + + String token = ExportedSnapshotStore.save(dir, saved); + EnclyMigrationRepository.Exported loaded = ExportedSnapshotStore.load(dir, token); + + assertNotNull(loaded); + assertArrayEquals(saved.notes, loaded.notes); + assertArrayEquals(saved.tasks, loaded.tasks); + assertArrayEquals(saved.tags, loaded.tags); + assertArrayEquals(saved.categories, loaded.categories); + assertArrayEquals(saved.noteFingerprints, loaded.noteFingerprints); + assertArrayEquals(saved.taskFingerprints, loaded.taskFingerprints); + assertArrayEquals(saved.tagFingerprints, loaded.tagFingerprints); + assertArrayEquals(saved.categoryFingerprints, loaded.categoryFingerprints); + } + + @Test + public void aNewHandoff_replacesTheOldSnapshot() throws IOException { + File dir = folder.getRoot(); + String first = ExportedSnapshotStore.save(dir, snapshot(3)); + + String second = ExportedSnapshotStore.save(dir, snapshot(4)); + + assertNull(ExportedSnapshotStore.load(dir, first)); + assertEquals(4, ExportedSnapshotStore.load(dir, second).notes.length); + } + + @Test + public void anUnknownOrMalformedToken_readsNothing() throws IOException { + File dir = folder.getRoot(); + ExportedSnapshotStore.save(dir, snapshot(3)); + + assertNull(ExportedSnapshotStore.load(dir, null)); + assertNull(ExportedSnapshotStore.load(dir, "1-abc")); + assertNull(ExportedSnapshotStore.load(dir, "../../etc/passwd")); + } + + @Test + public void aDamagedFile_readsNothing() throws IOException { + File dir = folder.getRoot(); + String token = ExportedSnapshotStore.save(dir, snapshot(3)); + File file = new File(dir, "exported-" + token + ".bin"); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(new byte[] {0, 0, 0, 1, 0x7f, 0x7f, 0x7f, 0x7f}); + } + + assertNull(ExportedSnapshotStore.load(dir, token)); + } + + @Test + public void aSweep_keepsSnapshotsSavedAfterItWasAskedFor() throws IOException { + File dir = folder.getRoot(); + String token = ExportedSnapshotStore.save(dir, snapshot(3)); + long savedAt = Long.parseLong(token.substring(0, token.indexOf('-'))); + + assertEquals(0, ExportedSnapshotStore.sweep(dir, savedAt)); + assertNotNull(ExportedSnapshotStore.load(dir, token)); + + assertEquals(1, ExportedSnapshotStore.sweep(dir, Long.MAX_VALUE)); + assertNull(ExportedSnapshotStore.load(dir, token)); + } +} From e1059e1f1f32802491042c8afe88ab127d6e53f0 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Fri, 13 Nov 2026 16:28:41 +0200 Subject: [PATCH 52/52] fix(export): keep a wrapped line's indent in the PDF export --- .../mynotes/utils/file/FileExportUtils.java | 29 ++++++++++++- .../mynotes/utils/file/PdfLineWrapTest.java | 41 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java index de81d19c..b7a5089f 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java +++ b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java @@ -283,6 +283,11 @@ public static boolean savePdfToUri( * *

Lines break between words; a single word wider than the whole line is broken between * characters, since a word that cannot fit anywhere was otherwise never drawn at all. + * + *

The line's leading whitespace is its indent (a nested list item, a code block), and every + * line drawn for it starts with it. Splitting on spaces used to turn the indent into empty + * words that the first line swallowed, so a wrapped line lost its indent while a short one kept + * it. An indent wider than half the line is cut to half, so some text always fits beside it. */ @VisibleForTesting static List wrapLine(String line, float maxWidth, TextWidth width) { @@ -291,8 +296,30 @@ static List wrapLine(String line, float maxWidth, TextWidth width) { out.add(line); return out; } + int bodyStart = 0; + while (bodyStart < line.length() + && (line.charAt(bodyStart) == ' ' || line.charAt(bodyStart) == '\t')) { + bodyStart++; + } + String indent = line.substring(0, bodyStart); + while (!indent.isEmpty() && width.of(indent) > maxWidth / 2) { + indent = indent.substring(0, indent.length() - 1); + } + float bodyWidth = maxWidth - width.of(indent); + for (String drawn : wrapWords(line.substring(bodyStart), bodyWidth, width)) { + out.add(indent + drawn); + } + if (out.isEmpty()) { + out.add(indent); + } + return out; + } + + /** Breaks text that starts with no whitespace into lines no wider than {@code maxWidth}. */ + private static List wrapWords(String text, float maxWidth, TextWidth width) { + List out = new ArrayList<>(); StringBuilder current = new StringBuilder(); - for (String word : line.split(" ", -1)) { + for (String word : text.split(" ", -1)) { String candidate = current.length() == 0 ? word : current + " " + word; if (width.of(candidate) <= maxWidth) { current.setLength(0); diff --git a/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java b/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java index 1a1041de..18956677 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java @@ -46,6 +46,47 @@ public void noDrawnLineIsWiderThanAllowed() { } } + @Test + public void anIndentedLine_keepsItsIndentOnEveryDrawnLine() { + assertEquals( + Arrays.asList(" aaa bbb", " ccc ddd"), + FileExportUtils.wrapLine(" aaa bbb ccc ddd", 11, CHARS)); + } + + @Test + public void anIndentedShortLine_isStillDrawnAsIs() { + assertEquals(List.of(" short"), FileExportUtils.wrapLine(" short", 10, CHARS)); + } + + @Test + public void anIndentedOverlongWord_isBrokenBesideTheIndent() { + List lines = FileExportUtils.wrapLine("\tabcdefgh", 4, CHARS); + + assertEquals(Arrays.asList("\tabc", "\tdef", "\tgh"), lines); + } + + @Test + public void anIndentWiderThanHalfTheLine_isCutSoTextStillFits() { + List lines = FileExportUtils.wrapLine(" abcd efgh", 8, CHARS); + + assertEquals(Arrays.asList(" abcd", " efgh"), lines); + for (String line : lines) { + assertTrue(line, line.length() <= 8); + } + } + + @Test + public void indentedSurrogatePairs_areNotSplit() { + String emoji = "😀"; + List lines = FileExportUtils.wrapLine(" " + emoji + emoji + emoji, 4, CHARS); + + for (String line : lines) { + assertTrue(line, line.startsWith(" ")); + assertTrue(!Character.isHighSurrogate(line.charAt(line.length() - 1))); + assertTrue(!Character.isLowSurrogate(line.charAt(2))); + } + } + @Test public void surrogatePairs_areNotSplit() { String emoji = "😀"; // one character, two chars