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/assets/editor/js/runtime.min.js b/app/src/main/assets/editor/js/runtime.min.js
index 8af8f0c0..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 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 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/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java b/app/src/main/java/com/pasich/mynotes/data/AppDataManager.java
index 7bb65fc2..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);
@@ -402,6 +407,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..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
@@ -196,7 +196,17 @@ 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
+ public Completable reviveNote(Note note) {
+ return Completable.fromAction(() -> syncMutationCoordinator.reviveNote(note));
}
@Override
@@ -306,6 +316,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/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/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/database/dao/SyncMetadataDao.java b/app/src/main/java/com/pasich/mynotes/data/database/dao/SyncMetadataDao.java
index 81fd0d4f..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);
@@ -64,7 +70,13 @@ public interface SyncMetadataDao {
+ "WHERE recordType = :recordType AND localId = :localId")
void touch(String recordType, long localId, long nowMillis);
- /** Retains the metadata row and records a monotonic tombstone timestamp. */
+ /**
+ * Retains the metadata row and records a monotonic tombstone timestamp.
+ *
+ *
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/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/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/data/sync/GoogleDriveSyncBackend.java b/app/src/main/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackend.java
index 5e986309..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
@@ -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,6 +1165,11 @@ private void uploadAttachmentOrConfirm(
if (sizeBytes < 0L) {
throw new IllegalArgumentException("An attachment upload needs a declared size");
}
+ // Whatever happens next, the remembered listing no longer describes this root. It is
+ // dropped once, up front: the upload itself never lists, and every caller holds this
+ // backend's lock, so nothing can remember a listing again before the upload ends. The
+ // discovery below lists afresh, and what it remembers includes what the upload created.
+ candidateListings.remove(memoKey(folderId, sha256));
try {
uploadStream(folderId, sha256, MIME_BINARY, content, sizeBytes);
} catch (IOException uploadFailure) {
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..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
@@ -56,10 +56,21 @@ 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";
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;
@@ -96,6 +107,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.
@@ -227,8 +248,31 @@ public SnapshotBuildResult buildSnapshot() throws IOException {
List problems = new ArrayList<>();
rememberedOnlyAttachments.clear();
builtVersionIds.clear();
+ builtUpdatedAts.clear();
try {
- for (SyncMetadataEntity metadata : database.syncMetadataDao().getAll()) {
+ 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;
+ }
// 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) {
@@ -243,22 +287,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();
@@ -269,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)
@@ -370,7 +447,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 "
@@ -418,7 +495,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
@@ -861,6 +941,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.
*
@@ -1242,6 +1335,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 +1505,7 @@ private void finalizeResolvedPreferencesConflict(long conflictId, @NonNull Strin
null);
}
database.syncConflictDao().markResolved(conflictId, resolution, resolvedAt);
+ database.syncConflictDao().deleteResolvedBeyond(MAX_KEPT_RESOLVED_CONFLICTS);
});
}
@@ -1672,6 +1767,34 @@ 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. What it learns is persisted by {@link
+ * #finishAttachmentVerification}.
+ */
+ @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;
+ }
+ }
+
+ /**
+ * 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/SyncMutationCoordinator.java b/app/src/main/java/com/pasich/mynotes/data/sync/SyncMutationCoordinator.java
index e2c84956..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
@@ -426,11 +426,24 @@ 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
+ * 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.
*/
- 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,6 +453,31 @@ public void updateNoteContent(@NonNull Note note) {
note.getTag(),
note.getAttachments());
touchRecord(SyncMetadata.RECORD_TYPE_NOTE, note.getId(), timeProvider.now());
+ return true;
+ });
+ }
+
+ /**
+ * 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;
});
}
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..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
@@ -32,7 +32,10 @@ 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;
+
+ /** 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;
@@ -151,9 +154,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()) {
@@ -190,24 +193,37 @@ 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;
+ // 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,
+ withoutExpiredTombstones(remote, alternatives.values()),
+ alternatives.keySet(),
+ settledVersionIds,
+ remoteResult)) {
backend.publish(
new SyncPublication(
- merged,
+ published,
new java.util.ArrayList<>(alternatives.values()),
settledVersionIds,
remoteResult));
@@ -262,24 +278,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;
}
@@ -288,6 +316,97 @@ private 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;
+ }
+
+ /**
+ * {@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.
*
@@ -402,21 +521,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..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
@@ -98,6 +98,32 @@ 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;
+ }
+ }
+
+ /**
+ * 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/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java b/app/src/main/java/com/pasich/mynotes/extendedEditor/NoteEditorView.java
index fe403257..5ad1f858 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);
@@ -486,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.
@@ -609,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 3252b6c7..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) {
@@ -619,9 +671,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/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 <} 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/main/java/com/pasich/mynotes/ui/contract/BackupContract.java b/app/src/main/java/com/pasich/mynotes/ui/contract/BackupContract.java
index ed27d98c..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
@@ -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;
@@ -38,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);
@@ -47,9 +55,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,7 +86,23 @@ 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);
+
+ /**
+ * 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/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/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/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/BackupPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/BackupPresenter.java
index 4b997a9a..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
@@ -2,9 +2,12 @@
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;
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 +21,14 @@
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.UUID;
+import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
/** Presenter backing the backup and restore screen. */
@@ -32,9 +36,44 @@
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. 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;
+ /** 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,
@@ -43,14 +82,50 @@ 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;
getView().initActivity();
getView().initListeners();
+ catchUpOnBackgroundWork();
}
@Override
public void detachView() {
+ if (BackgroundWork.current == this) {
+ BackgroundWork.current = null;
+ }
super.detachView();
}
@@ -105,80 +180,196 @@ 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) {
+ // 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;
+ BackgroundWork.saveOwner = screenToken;
+ 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. 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();
+ BackgroundWork.restoreRunning = true;
+ BackgroundWork.restoreOwner = screenToken;
- 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) {
+ rejectRestoreWhileRunning();
+ return;
+ }
+ BackgroundWork.restoreRunning = true;
+ BackgroundWork.restoreOwner = screenToken;
+ 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());
+ });
+ }
+
+ /** 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);
+ } 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;
+ String owner = BackgroundWork.restoreOwner;
+ BackgroundWork.restoreOwner = null;
+ BackupPresenter current = BackgroundWork.current;
+ if (current != null && current.isViewAttached()) {
+ current.deliverRestore(infoCode);
+ } else if (owner != null) {
+ BackgroundWork.undeliveredRestore = infoCode;
+ BackgroundWork.undeliveredRestoreOwner = owner;
+ }
+ }
+
+ private void deliverRestore(int infoCode) {
+ if (infoCode == CloudErrors.OKAY_RESTORE) {
+ getView().onRestoreSuccessFlag();
+ }
+ getView().restoreFinish(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 if (owner != null) {
+ BackgroundWork.undeliveredSave = saved;
+ BackgroundWork.undeliveredSaveOwner = owner;
+ }
+ }
+
+ /**
+ * 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
+ && screenToken.equals(BackgroundWork.undeliveredRestoreOwner)) {
+ int infoCode = BackgroundWork.undeliveredRestore;
+ BackgroundWork.undeliveredRestore = null;
+ BackgroundWork.undeliveredRestoreOwner = null;
+ deliverRestore(infoCode);
+ }
+ if (BackgroundWork.undeliveredSave != null
+ && screenToken.equals(BackgroundWork.undeliveredSaveOwner)) {
+ boolean saved = BackgroundWork.undeliveredSave;
+ BackgroundWork.undeliveredSave = null;
+ BackgroundWork.undeliveredSaveOwner = null;
+ getView().createLocalCopyFinish(saved);
+ }
}
/**
@@ -248,29 +439,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 +538,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/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/presenter/NotePresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/NotePresenter.java
index d4321b79..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
@@ -3,8 +3,10 @@
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.database.NoteDeletedException;
import com.pasich.mynotes.data.model.Note;
import com.pasich.mynotes.extendedEditor.models.ParsedNote;
import com.pasich.mynotes.extendedEditor.utils.EditorDocument;
@@ -14,11 +16,14 @@
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;
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;
@@ -33,7 +38,7 @@ 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;
+ private volatile long idKey;
private boolean newNoteKey;
private boolean extendedEditor = false;
private boolean isSavingInProgress = false;
@@ -46,6 +51,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 +189,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());
@@ -191,21 +212,29 @@ public void flushPending() {
updateSaveState(SaveState.SAVING);
if (flushSave != null) flushSave.dispose();
flushSave =
- getDataManager()
- .updateNote(note)
+ writeNote(note)
.subscribeOn(getSchedulerProvider().io())
.observeOn(getSchedulerProvider().ui())
.subscribe(
() -> {
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;
@@ -285,8 +314,7 @@ private void saveNote(Note note, NoteContract.AutoSaveCallback callback) {
getCompositeDisposable()
.add(
- getDataManager()
- .updateNote(note)
+ writeNote(note)
.subscribeOn(getSchedulerProvider().io())
.observeOn(getSchedulerProvider().ui())
.subscribe(
@@ -300,6 +328,23 @@ 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), 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 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);
+ });
+ }
+
/**
* Emergency saving when Activity is destroyed but there is unsaved data. Performed
* synchronously to ensure saving. Only NoteActivity (SimpleEditor)
@@ -313,8 +358,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/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java b/app/src/main/java/com/pasich/mynotes/ui/presenter/TasksPresenter.java
index 6f46c3e7..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
@@ -126,6 +131,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()
@@ -178,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/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/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/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/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/ui/sync/SyncCoordinator.java b/app/src/main/java/com/pasich/mynotes/ui/sync/SyncCoordinator.java
index f4dfc4c3..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
@@ -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,26 +264,51 @@ public void syncNow(@NonNull Activity activity, @NonNull Callback cal
new IllegalStateException("Confirm the first sync before continuing"));
return;
}
+ // 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) {
- runOnWorker(
- callback,
- () -> {
- try {
- preferenceHelper.setSyncEnabled(true);
- 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 (disconnectGeneration.get() != generationAtTap
+ || !firebaseGoogleAuth.isSignedIn()) {
+ deliverError(
+ callback,
+ new IllegalStateException(
+ "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);
+ 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
@@ -311,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/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..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;
@@ -82,7 +83,10 @@
/** 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,
+ OtherAppImportDialog.Host {
private static final String TAG = "BackupActivity";
@@ -166,7 +170,7 @@ public class BackupActivity extends BaseActivity
@Nullable private Uri pendingRestorePick;
private static final String STATE_PENDING_RESTORE_PICK = "pendingRestorePick";
- private OtherAppImportDialog importDialog;
+ private static final String STATE_SCREEN_TOKEN = "backupScreenToken";
private Dialog progressDialog;
private RoomSyncStore roomSyncStore;
private SyncCoordinatorFactory.Result syncSetup;
@@ -213,10 +217,17 @@ 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);
setupTabs();
+ resumeOtherAppImport();
if (savedInstanceState != null) {
Uri pick = savedInstanceState.getParcelable(STATE_PENDING_RESTORE_PICK);
@@ -970,6 +981,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);
}
@@ -987,6 +999,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();
}
@@ -1001,115 +1017,134 @@ 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 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);
@@ -1181,6 +1216,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)
@@ -1210,25 +1249,63 @@ 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");
}
@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/activity/MainActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MainActivity.java
index e114e301..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() {
@@ -510,7 +513,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 +1014,8 @@ public void deleteTag() {
@Override
public void renameTag() {
- new NameTagDialog(tag).show(getSupportFragmentManager(), "RenameTag");
+ NameTagDialog.newRenameInstance(tag)
+ .show(getSupportFragmentManager(), "RenameTag");
}
@Override
@@ -1113,7 +1117,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/activity/MeetEnclyActivity.java b/app/src/main/java/com/pasich/mynotes/ui/view/activity/MeetEnclyActivity.java
index 83103967..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,6 +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_TOKEN = "encly_exported_token";
@Inject EnclyMigrationRepository repository;
@Inject UpdateChecker updateChecker;
@@ -80,6 +82,15 @@ 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;
+
+ /**
+ * 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);
@@ -101,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);
}
@@ -274,7 +286,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 +354,23 @@ private void startHandoff(@NonNull String activityClassName) {
Single.fromCallable(
() -> {
repository.clearArchives();
- return repository.writeArchive(repository.load());
+ EnclyMigrationRepository.Library library = repository.load();
+ 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(
- file -> launchEncly(file, activityClassName),
+ prepared -> {
+ exported = prepared.exported;
+ exportedToken = prepared.token;
+ launchEncly(prepared.archive, activityClassName);
+ },
error -> {
Log.e(TAG, "prepare hand-off", error);
clearArchivesInBackground();
@@ -446,16 +469,35 @@ 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(
+ () -> {
+ 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(
() -> {
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(
@@ -483,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);
@@ -494,10 +559,22 @@ protected void onSaveInstanceState(@NonNull Bundle outState) {
outState.putInt(STATE_SKIPPED, resultCounts[3]);
outState.putString(STATE_REASON, resultReason);
outState.putBoolean(STATE_CLEARED, cleared);
+ outState.putString(STATE_EXPORTED_TOKEN, exportedToken);
+ }
+
+ /**
+ * 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);
+ // 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 =
@@ -535,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/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/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/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/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..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
@@ -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);
@@ -193,10 +220,21 @@ 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()) {
- 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.keepForRecreation(
+ isChangingConfigurations(), notePresenter.getIdKey(), title, value);
+ keptInMemory = true;
+ }
}
+ if (!keptInMemory && retainedDraft != null) retainedDraft.discard();
saveEditHistory(outState);
}
@@ -689,6 +727,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..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
@@ -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,11 @@ public class NoteExtendedEditorActivity
private String draftTitle;
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) {
@@ -104,6 +110,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,8 +171,19 @@ 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 {
+ // 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
@@ -247,12 +265,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 +489,34 @@ 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) {
+ // 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);
+ }
+ }
+
+ @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 +590,18 @@ 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,
+ () -> {
+ pageHandedOver = true;
+ draft.previousPageFinished();
+ });
+ } else {
+ binding.noteEditor.release(false);
+ }
if (binding.noteEditor.getParent() instanceof ViewGroup) {
((ViewGroup) binding.noteEditor.getParent()).removeView(binding.noteEditor);
}
@@ -574,10 +635,25 @@ 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) {
+ 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.
+ 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 +668,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/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/ui/view/dialogs/MoreNoteDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/MoreNoteDialog.java
index 9e88dd7f..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
@@ -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) {
@@ -252,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();
});
@@ -298,14 +292,21 @@ 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();
});
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/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/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/ShareOptionsDialog.java b/app/src/main/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialog.java
index 965a2800..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,153 +1,304 @@
package com.pasich.mynotes.ui.view.dialogs;
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;
+ public static ShareOptionsDialog newInstance(@NonNull Note note) {
+ return create(Collections.singletonList(note), true, false);
}
- public ShareOptionsDialog(List selectedNotes) {
- this();
- this.mNote = null;
- this.mSelectedNotes = selectedNotes;
- this.isDataExport = false;
+ public static ShareOptionsDialog newInstance(@NonNull List selectedNotes) {
+ return create(selectedNotes, false, false);
}
- public ShareOptionsDialog(List selectedNotes, boolean isDataExport) {
- this();
- this.mNote = null;
- this.mSelectedNotes = selectedNotes;
- this.isDataExport = isDataExport;
+ public static ShareOptionsDialog newInstance(
+ @NonNull List selectedNotes, boolean isDataExport) {
+ return create(selectedNotes, false, isDataExport);
}
- public static ShareOptionsDialog newInstance(Note note) {
+ private static ShareOptionsDialog create(
+ List selectedNotes, boolean single, boolean isDataExport) {
ShareOptionsDialog dialog = new ShareOptionsDialog();
- dialog.mNote = note;
- dialog.mSelectedNotes = null;
- dialog.isDataExport = false;
+ dialog.setArguments(arguments(selectedNotes, single, isDataExport));
+ dialog.notes = new ArrayList<>(selectedNotes);
return dialog;
}
- public static ShareOptionsDialog newInstance(List selectedNotes) {
- ShareOptionsDialog dialog = new ShareOptionsDialog();
- dialog.mNote = null;
- dialog.mSelectedNotes = selectedNotes;
- dialog.isDataExport = false;
- 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;
}
- public static ShareOptionsDialog newInstance(List selectedNotes, boolean isDataExport) {
- ShareOptionsDialog dialog = new ShareOptionsDialog();
- dialog.mNote = null;
- dialog.mSelectedNotes = selectedNotes;
- dialog.isDataExport = isDataExport;
- return dialog;
+ /** 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 +319,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 +353,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/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));
}
}
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/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..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,29 +121,54 @@ 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;
+
@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.
+ Log.w(TAG, "Notification volume change refused: " + e.getMessage());
+ 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) {}
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ tracking = true;
+ refused = false;
+ }
@Override
- public void onStopTrackingTouch(SeekBar seekBar) {}
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ 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));
+ }
});
}
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