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 &lt;} gives {@code <} and not {@code <}. */ + private static String decodeEntities(String text) { + if (text.indexOf('&') < 0) return text; + Matcher matcher = ENTITY.matcher(text); + StringBuilder out = new StringBuilder(text.length()); + int last = 0; + while (matcher.find()) { + out.append(text, last, matcher.start()); + out.append(decodeEntity(matcher.group(1), matcher.group())); + last = matcher.end(); + } + out.append(text, last, text.length()); + return out.toString(); + } + + private static String decodeEntity(String name, String original) { + switch (name) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return "\""; + case "apos": + return "'"; + case "nbsp": + return " "; + default: + try { + boolean hex = name.charAt(1) == 'x' || name.charAt(1) == 'X'; + int code = Integer.parseInt(name.substring(hex ? 2 : 1), hex ? 16 : 10); + if (code == 0xA0) return " "; + return Character.isValidCodePoint(code) + ? new String(Character.toChars(code)) + : original; + } catch (NumberFormatException e) { + return original; + } + } } } diff --git a/app/src/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 keys = new ArrayList<>(list.size()); + for (Object item : list) keys.add(item instanceof Task ? snapshot((Task) item) : item); + + List oldKeys = displayKeys; + boolean headerChanged = + shownCompletedCount != completedTasks.size() || shownExpanded != completedExpanded; displayList = list; - notifyDataSetChanged(); + displayKeys = keys; + shownCompletedCount = completedTasks.size(); + shownExpanded = completedExpanded; + // Only the rows that changed are rebound; every database change used to rebind them all. + DiffUtil.calculateDiff(new DisplayDiff(oldKeys, keys, headerChanged)) + .dispatchUpdatesTo(this); + } + + private List displayKeys = new ArrayList<>(); + private int shownCompletedCount = -1; + private boolean shownExpanded; + + private static Task snapshot(Task t) { + Task copy = new Task(); + copy.setId(t.getId()); + copy.setTitle(t.getTitle()); + copy.setDescription(t.getDescription()); + copy.setDone(t.isDone()); + copy.setCategoryId(t.getCategoryId()); + copy.setCreatedAt(t.getCreatedAt()); + copy.setPosition(t.getPosition()); + copy.setReminderTime(t.getReminderTime()); + copy.setReminderIntervalMinutes(t.getReminderIntervalMinutes()); + return copy; + } + + /** Rows are the same task by id; a task row changes when any of its fields changes. */ + static final class DisplayDiff extends DiffUtil.Callback { + private final List oldList; + private final List newList; + private final boolean headerChanged; + + DisplayDiff(List oldList, List newList, boolean headerChanged) { + this.oldList = oldList; + this.newList = newList; + this.headerChanged = headerChanged; + } + + @Override + public int getOldListSize() { + return oldList.size(); + } + + @Override + public int getNewListSize() { + return newList.size(); + } + + @Override + public boolean areItemsTheSame(int oldPos, int newPos) { + Object a = oldList.get(oldPos); + Object b = newList.get(newPos); + if (a instanceof Task && b instanceof Task) { + return ((Task) a).getId() == ((Task) b).getId() + && ((Task) a).isDone() == ((Task) b).isDone(); + } + return a.equals(b); + } + + @Override + public boolean areContentsTheSame(int oldPos, int newPos) { + Object a = oldList.get(oldPos); + Object b = newList.get(newPos); + if (a instanceof Task && b instanceof Task) return sameContent((Task) a, (Task) b); + if (SENTINEL_HEADER.equals(a)) return !headerChanged; + return true; + } + } + + /** + * Compares every stored field: an unchanged row keeps the Task it was bound with, and its click + * handlers hand that object back to be written, so it must not be stale in any field. + */ + static boolean sameContent(Task a, Task b) { + return a.isDone() == b.isDone() + && a.getPosition() == b.getPosition() + && a.getCreatedAt() == b.getCreatedAt() + && a.getCategoryId() == b.getCategoryId() + && Objects.equals(a.getTitle(), b.getTitle()) + && Objects.equals(a.getDescription(), b.getDescription()) + && Objects.equals(a.getReminderTime(), b.getReminderTime()) + && a.getReminderIntervalMinutes() == b.getReminderIntervalMinutes(); } public boolean moveItem(int fromPos, int toPos) { if (!isActiveTask(fromPos) || !isActiveTask(toPos)) return false; Collections.swap(activeTasks, fromPos, toPos); Collections.swap(displayList, fromPos, toPos); + Collections.swap(displayKeys, fromPos, toPos); notifyItemMoved(fromPos, toPos); return true; } diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java b/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java index dfcfcb5a..8a2f5710 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/BackupCacheHelper.java @@ -2,7 +2,9 @@ import com.pasich.mynotes.utils.backup.models.JsonBackup; import javax.inject.Inject; +import javax.inject.Singleton; +@Singleton public class BackupCacheHelper { private JsonBackup jsonBackup; diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java b/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java index 85078491..84edb28a 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/local/LocalBackup.java @@ -4,24 +4,35 @@ import android.net.Uri; import android.os.ParcelFileDescriptor; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import com.pasich.mynotes.utils.backup.BackupCacheHelper; import com.pasich.mynotes.utils.backup.ScramblerBackupHelper; import com.pasich.mynotes.utils.backup.models.JsonBackup; import dagger.hilt.android.qualifiers.ApplicationContext; +import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import javax.inject.Inject; import javax.inject.Singleton; +/** + * Reads and writes backup files the user picked. + * + *

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

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

Everything is streamed — the JSON straight from Gson, each attachment in chunks — so the + * size of the library, not of the heap, is the limit. + */ + public static File writeZipBackup(Context ctx, JsonBackup backup) throws IOException { File zipFile = new File(ctx.getCacheDir(), FILE_NAME_BACKUP_MNBKN); - ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile)); - - // Write backup.json - zos.putNextEntry(new ZipEntry(FILE_NAME_BACKUP)); - String json = new Gson().toJson(backup); - zos.write(json.getBytes(StandardCharsets.UTF_8)); - zos.closeEntry(); - - // Write attachments (full structure) - File baseDir = new File(ctx.getFilesDir(), ATTACHMENTS_BASE_DIR); + try { + writeZipBackup(backup, new File(ctx.getFilesDir(), ATTACHMENTS_BASE_DIR), zipFile); + } catch (IOException | RuntimeException e) { + if (zipFile.exists() && !zipFile.delete()) { + Log.w(TAG, "Could not remove a partly written backup archive"); + } + throw e; + } + return zipFile; + } - if (baseDir.exists()) { - File[] noteDirs = baseDir.listFiles(); + /** Filesystem-only core of {@link #writeZipBackup(Context, JsonBackup)}. */ + static void writeZipBackup( + @NonNull JsonBackup backup, @NonNull File attachmentsDir, @NonNull File zipFile) + throws IOException { + try (ZipOutputStream zos = + new ZipOutputStream( + new BufferedOutputStream(new FileOutputStream(zipFile), 64 * 1024))) { - if (noteDirs != null) { - for (File noteDir : noteDirs) { + // Write backup.json + zos.putNextEntry(new ZipEntry(FILE_NAME_BACKUP)); + Writer writer = new OutputStreamWriter(zos, StandardCharsets.UTF_8); + new Gson().toJson(backup, writer); + // Flushed, not closed: closing the writer would close the archive. + writer.flush(); + zos.closeEntry(); - if (!noteDir.isDirectory()) continue; - File[] files = noteDir.listFiles(); + // Write attachments (full structure) + File[] noteDirs = attachmentsDir.listFiles(); + if (noteDirs == null) return; - if (files == null) continue; + byte[] chunk = new byte[64 * 1024]; + for (File noteDir : noteDirs) { + if (!noteDir.isDirectory()) continue; + File[] files = noteDir.listFiles(); + if (files == null) continue; - for (File attachment : files) { - String entryName = - ATTACHMENTS_BASE_DIR - + "/" - + noteDir.getName() - + "/" - + attachment.getName(); + for (File attachment : files) { + if (!attachment.isFile()) continue; + String entryName = + ATTACHMENTS_BASE_DIR + + "/" + + noteDir.getName() + + "/" + + attachment.getName(); - zos.putNextEntry(new ZipEntry(entryName)); - zos.write(Files.readAllBytes(attachment.toPath())); - zos.closeEntry(); + zos.putNextEntry(new ZipEntry(entryName)); + try (InputStream in = new FileInputStream(attachment)) { + int n; + while ((n = in.read(chunk)) != -1) { + zos.write(chunk, 0, n); + } } + zos.closeEntry(); } } } - - zos.close(); - return zipFile; } /** diff --git a/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java b/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java index edafcb90..33329532 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java +++ b/app/src/main/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNote.java @@ -21,10 +21,50 @@ public class GoogleKeepNote { @SerializedName("labels") private List labels; + /** A checklist note's items; Keep leaves {@code textContent} out for these. */ + @SerializedName("listContent") + private List listContent; + + /** One line of a Keep checklist. */ + public static class ListItem { + @SerializedName("text") + private String text; + + @SerializedName("isChecked") + private boolean isChecked; + } + public String getTextContent() { return textContent; } + /** + * The note's body as plain text. + * + *

A checklist note carries its items in {@code listContent}, not {@code textContent}. They + * are written the way the app itself turns a checklist into plain text ("[ ] item", "[x] + * item"), so the items and their state survive the import instead of arriving as an empty note. + */ + public String getBodyText() { + boolean hasText = textContent != null && !textContent.isEmpty(); + if (listContent == null || listContent.isEmpty()) { + return hasText ? textContent : ""; + } + StringBuilder body = new StringBuilder(); + if (hasText) { + body.append(textContent); + } + for (ListItem item : listContent) { + if (item == null) continue; + if (body.length() > 0) { + body.append('\n'); + } + body.append(item.isChecked ? "[x] " : "[ ] ") + .append(item.text == null ? "" : item.text); + } + return body.toString(); + } + public String getTitle() { return title; } @@ -53,13 +93,13 @@ public GoogleKeepLabel getFirstLabel() { public Note toAppNotes() { Note note = new Note(); long timestamp = userEditedTimestampUsec / 1000; - return note.create(title, textContent, timestamp, getFirstLabel().getName()); + return note.create(title, getBodyText(), timestamp, getFirstLabel().getName()); } public Note toAppNotesTrash() { Note note = new Note(); note.setTrash(true); long timestamp = userEditedTimestampUsec / 1000; - return note.create(title, textContent, timestamp, getFirstLabel().getName()); + return note.create(title, getBodyText(), timestamp, getFirstLabel().getName()); } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java b/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java index 095601f3..e550f3ac 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethod.java @@ -5,6 +5,7 @@ import android.text.Selection; import android.text.Spannable; import android.text.method.ArrowKeyMovementMethod; +import android.text.method.MetaKeyKeyListener; import android.text.method.MovementMethod; import android.text.style.URLSpan; import android.util.Log; @@ -68,6 +69,9 @@ public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event // selecting started, nothing was recorded and it selects from -1, which throws. The // selection is left as it was rather than taking the app down. Log.w(TAG, "Selection gesture without a recorded start", e); + // The platform would have ended selecting mode after this event; without that, Shift + // stays held and every later tap extends a selection. + MetaKeyKeyListener.resetMetaState(buffer); return true; } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java new file mode 100644 index 00000000..cf215add --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/editor/RetainedNoteDraft.java @@ -0,0 +1,119 @@ +package com.pasich.mynotes.utils.editor; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModel; +import java.util.ArrayList; +import java.util.List; + +/** + * Carries a note's latest unsaved edits across a recreation of the editor screen, such as a + * rotation, in memory. + * + *

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

Used on the main thread only. + */ +public class RetainedNoteDraft extends ViewModel { + + /** A note's title and body (plain text or the extended editor's JSON); null when unchanged. */ + public static final class Draft { + @Nullable public final String title; + @Nullable public final String body; + + Draft(@Nullable String title, @Nullable String body) { + this.title = title; + this.body = body; + } + } + + private long noteId; + @Nullable private String title; + @Nullable private String body; + // Pages of replaced screens still handing over their last edit. + private int pagesFinishing; + private final List afterPreviousPage = new ArrayList<>(); + + /** Keeps the latest draft of note {@code noteId}; a null part keeps what was kept before. */ + @MainThread + public void put(long noteId, @Nullable String title, @Nullable String body) { + if (noteId != this.noteId) { + this.title = null; + this.body = null; + } + this.noteId = noteId; + if (title != null) this.title = title; + if (body != null) this.body = body; + } + + /** + * Called as the screen saves its state. Only a screen that is being recreated keeps a draft + * here: a screen that only goes to the background comes back as itself, keeps editing and + * saving, and a draft left here would later be shown, and saved, over that newer content. Such + * a save forgets any draft still kept instead. + * + * @param recreating whether the screen is being recreated (its isChangingConfigurations) + */ + @MainThread + public void keepForRecreation( + boolean recreating, long noteId, @Nullable String title, @Nullable String body) { + if (recreating) put(noteId, title, body); + else discard(); + } + + /** + * Forgets the kept draft. A screen calls this once it holds the note and saves its state + * without being recreated, so no draft older than what it holds is left for a later screen. + */ + @MainThread + public void discard() { + title = null; + body = null; + } + + /** The draft kept for {@code noteId}, which is then forgotten; null when there is none. */ + @Nullable + @MainThread + public Draft take(long noteId) { + boolean ours = noteId > 0 && noteId == this.noteId && (title != null || body != null); + Draft draft = ours ? new Draft(title, body) : null; + title = null; + body = null; + return draft; + } + + /** The screen is being recreated and its page is still handing over its last edit. */ + @MainThread + public void previousPageFinishing() { + pagesFinishing++; + } + + /** A finishing page has handed over everything it had, or will never answer. */ + @MainThread + public void previousPageFinished() { + if (pagesFinishing > 0) pagesFinishing--; + if (pagesFinishing > 0) return; + List waiting = new ArrayList<>(afterPreviousPage); + afterPreviousPage.clear(); + for (Runnable action : waiting) action.run(); + } + + /** Runs {@code action} once the page of the screen this one replaced has finished. */ + @MainThread + public void whenPreviousPageFinished(@NonNull Runnable action) { + if (pagesFinishing > 0) afterPreviousPage.add(action); + else action.run(); + } + + @Override + protected void onCleared() { + afterPreviousPage.clear(); + title = null; + body = null; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java index bd82f64f..c15ae3fb 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/EnclyMigrationRepository.java @@ -2,6 +2,7 @@ import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; @@ -17,9 +18,17 @@ import dagger.hilt.android.qualifiers.ApplicationContext; import java.io.File; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.ToLongFunction; import javax.inject.Inject; import javax.inject.Singleton; @@ -97,25 +106,251 @@ public void clearArchives() { } /** - * Deletes notes, tasks, tags, task categories and attachment files. Only ever called after - * Encly confirmed the move and the user confirmed this separate step. + * Keeps what a hand-off carried until Encly answers; see {@link ExportedSnapshotStore}. + * + * @return the token that reads it back. */ @WorkerThread - public void clearAllData() { + @NonNull + public String saveExported(@NonNull Exported exported) throws IOException { + return ExportedSnapshotStore.save(exportedDir(), exported); + } + + /** What the hand-off saved under {@code token} carried, or null when it is gone. */ + @WorkerThread + @Nullable + public Exported loadExported(@Nullable String token) { + return ExportedSnapshotStore.load(exportedDir(), token); + } + + /** Deletes the hand-off snapshots saved before {@code requestedAt}. */ + @WorkerThread + public void forgetExported(long requestedAt) { + ExportedSnapshotStore.sweep(exportedDir(), requestedAt); + } + + private File exportedDir() { + return new File(context.getNoBackupFilesDir(), "encly-handoff"); + } + + /** + * Which records a hand-off carried, and what each held when it was carried. + * + *

The clear offered afterwards removes exactly these. It used to remove whatever the + * database held when the button was pressed, including a note written, or synced in, after the + * archive was made, which never reached Encly. A carried record changed since then (edited + * here, or by a sync) stays too: Encly only has its older content. Each id has the fingerprint + * of its content at the same index. + */ + public static final class Exported { + public final long[] notes; + public final long[] tasks; + public final long[] tags; + public final long[] categories; + public final long[] noteFingerprints; + public final long[] taskFingerprints; + public final long[] tagFingerprints; + public final long[] categoryFingerprints; + + public Exported( + long[] notes, + long[] tasks, + long[] tags, + long[] categories, + long[] noteFingerprints, + long[] taskFingerprints, + long[] tagFingerprints, + long[] categoryFingerprints) { + this.notes = notes; + this.tasks = tasks; + this.tags = tags; + this.categories = categories; + this.noteFingerprints = noteFingerprints; + this.taskFingerprints = taskFingerprints; + this.tagFingerprints = tagFingerprints; + this.categoryFingerprints = categoryFingerprints; + } + + @NonNull + public static Exported of(@NonNull Library library) { + return new Exported( + longs(library.notes, Note::getId), + longs(library.tasks, Task::getId), + longs(library.tags, Tag::getId), + longs(library.categories, TaskCategory::getId), + longs(library.notes, EnclyMigrationRepository::fingerprint), + longs(library.tasks, EnclyMigrationRepository::fingerprint), + longs(library.tags, EnclyMigrationRepository::fingerprint), + longs(library.categories, EnclyMigrationRepository::fingerprint)); + } + + private static long[] longs(List items, ToLongFunction value) { + if (items == null) return new long[0]; + long[] out = new long[items.size()]; + for (int i = 0; i < out.length; i++) out[i] = value.applyAsLong(items.get(i)); + return out; + } + } + + /** + * The items of {@code current} whose id is among {@code exported} and whose content still has + * the fingerprint it had then ({@code fingerprints}, at the same index as the id). + */ + @NonNull + static List onlyExportedUnchanged( + @Nullable List current, + @NonNull long[] exported, + @NonNull long[] fingerprints, + @NonNull ToLongFunction id, + @NonNull ToLongFunction fingerprint) { + List out = new ArrayList<>(); + if (current == null || exported.length == 0 || exported.length != fingerprints.length) { + return out; + } + Map wanted = new HashMap<>(); + for (int i = 0; i < exported.length; i++) wanted.put(exported[i], fingerprints[i]); + for (T item : current) { + Long carried = wanted.get(id.applyAsLong(item)); + if (carried != null && carried == fingerprint.applyAsLong(item)) out.add(item); + } + return out; + } + + /** A note's content as the hand-off carries it. */ + static long fingerprint(@NonNull Note note) { + return fingerprintOf( + note.getTitle(), + note.getValue(), + note.getValueJson(), + note.getTag(), + note.getAttachments(), + String.valueOf(note.isTrash()), + String.valueOf(note.isPinned())); + } + + /** A task's content as the hand-off carries it; positions are left out, others shift them. */ + static long fingerprint(@NonNull Task task) { + return fingerprintOf( + task.getTitle(), + task.getDescription(), + String.valueOf(task.isDone()), + String.valueOf(task.getCategoryId()), + String.valueOf(task.getCreatedAt())); + } + + static long fingerprint(@NonNull Tag tag) { + return fingerprintOf(tag.getNameTag()); + } + + static long fingerprint(@NonNull TaskCategory category) { + return fingerprintOf(category.getName(), category.getColorHex()); + } + + /** The first 64 bits of a SHA-256 over the parts, each length-prefixed so none can blur. */ + private static long fingerprintOf(String... parts) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + for (String part : parts) { + if (part == null) { + digest.update((byte) 0); + continue; + } + byte[] bytes = part.getBytes(StandardCharsets.UTF_8); + digest.update((byte) 1); + digest.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); + digest.update(bytes); + } + return ByteBuffer.wrap(digest.digest()).getLong(); + } + + /** + * Deletes the notes, tasks, tags and task categories the hand-off carried, with the notes' + * attachment files. Only ever called after Encly confirmed the move and the user confirmed this + * separate step. Anything added since the archive was made stays, and so does anything changed + * since then: Encly never received that content. + * + * @return how many carried records were kept because they changed since + */ + @WorkerThread + public int clearExported(@NonNull Exported exported) { Library library = load(); - for (Note note : library.notes) { + List notes = + onlyExportedUnchanged( + library.notes, + exported.notes, + exported.noteFingerprints, + Note::getId, + EnclyMigrationRepository::fingerprint); + List tasks = + onlyExportedUnchanged( + library.tasks, + exported.tasks, + exported.taskFingerprints, + Task::getId, + EnclyMigrationRepository::fingerprint); + List tags = + onlyExportedUnchanged( + library.tags, + exported.tags, + exported.tagFingerprints, + Tag::getId, + EnclyMigrationRepository::fingerprint); + List categories = + onlyExportedUnchanged( + library.categories, + exported.categories, + exported.categoryFingerprints, + TaskCategory::getId, + EnclyMigrationRepository::fingerprint); + int changed = + stillPresent(library.notes, exported.notes, Note::getId) + + stillPresent(library.tasks, exported.tasks, Task::getId) + + stillPresent(library.tags, exported.tags, Tag::getId) + + stillPresent(library.categories, exported.categories, TaskCategory::getId) + - notes.size() + - tasks.size() + - tags.size() + - categories.size(); + + for (Note note : notes) { if (note.getReminderTime() != null) { ReminderManager.cancelReminder(context, note.getId()); } } - for (Task task : library.tasks) { + for (Task task : tasks) { if (task.getReminderTime() != null) { TaskReminderManager.cancelReminder(context, task.getId()); } } - syncMutationCoordinator.clearAllUserData(); - new NoteViewStateStore(context).clear(); - deleteRecursively(AttachmentStorage.baseDirPath(context)); + database.runInTransaction(() -> { + syncMutationCoordinator.deleteNotes(notes); + for (Task task : tasks) syncMutationCoordinator.deleteTask(task); + for (Tag tag : tags) syncMutationCoordinator.deleteTag(tag); + for (TaskCategory category : categories) + syncMutationCoordinator.deleteCategory(category); + }); + NoteViewStateStore viewState = new NoteViewStateStore(context); + File attachments = AttachmentStorage.baseDirPath(context); + for (Note note : notes) { + viewState.remove(note.getId()); + deleteRecursively(new File(attachments, "note_" + note.getId())); + } + return changed; + } + + /** How many of {@code current} have an id among {@code exported}. */ + private static int stillPresent( + @Nullable List current, @NonNull long[] exported, @NonNull ToLongFunction id) { + if (current == null || exported.length == 0) return 0; + Set wanted = new HashSet<>(); + for (long value : exported) wanted.add(value); + int count = 0; + for (T item : current) if (wanted.contains(id.applyAsLong(item))) count++; + return count; } private static String key(String recordType, long localId) { diff --git a/app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java b/app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java new file mode 100644 index 00000000..21d0d7a7 --- /dev/null +++ b/app/src/main/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStore.java @@ -0,0 +1,170 @@ +package com.pasich.mynotes.utils.encly; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.SecureRandom; + +/** + * Keeps the record of what a hand-off carried ({@link EnclyMigrationRepository.Exported}) on disk + * while the Meet Encly screen waits for Encly's answer. + * + *

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

A token is the time it was made and a random part. A sweep deletes only files made before it + * was asked for, so a sweep that runs late cannot take a snapshot saved after it. + */ +public final class ExportedSnapshotStore { + + private static final String PREFIX = "exported-"; + private static final String SUFFIX = ".bin"; + private static final int FORMAT = 1; + private static final SecureRandom RANDOM = new SecureRandom(); + + private ExportedSnapshotStore() {} + + /** + * Writes the snapshot into {@code dir}, replacing any saved before it. + * + * @return the token that reads it back. + */ + @NonNull + public static synchronized String save( + @NonNull File dir, @NonNull EnclyMigrationRepository.Exported exported) + throws IOException { + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Cannot create " + dir); + } + String token = System.currentTimeMillis() + "-" + Long.toHexString(RANDOM.nextLong() >>> 1); + File file = fileFor(dir, token); + File temp = new File(dir, PREFIX + token + ".tmp"); + try (DataOutputStream out = + new DataOutputStream(new BufferedOutputStream(new FileOutputStream(temp)))) { + out.writeInt(FORMAT); + writeLongs(out, exported.notes); + writeLongs(out, exported.tasks); + writeLongs(out, exported.tags); + writeLongs(out, exported.categories); + writeLongs(out, exported.noteFingerprints); + writeLongs(out, exported.taskFingerprints); + writeLongs(out, exported.tagFingerprints); + writeLongs(out, exported.categoryFingerprints); + } catch (IOException e) { + temp.delete(); + throw e; + } + if (!temp.renameTo(file)) { + temp.delete(); + throw new IOException("Cannot store " + file); + } + File[] files = dir.listFiles(); + if (files != null) { + for (File other : files) { + if (other.getName().startsWith(PREFIX) && !other.equals(file)) other.delete(); + } + } + return token; + } + + /** The snapshot saved under {@code token}, or null when it is gone or unreadable. */ + @Nullable + public static synchronized EnclyMigrationRepository.Exported load( + @NonNull File dir, @Nullable String token) { + if (token == null || !isToken(token)) return null; + File file = fileFor(dir, token); + if (!file.isFile()) return null; + try (DataInputStream in = + new DataInputStream(new BufferedInputStream(new FileInputStream(file)))) { + if (in.readInt() != FORMAT) return null; + long max = file.length() / Long.BYTES; + long[] notes = readLongs(in, max); + long[] tasks = readLongs(in, max); + long[] tags = readLongs(in, max); + long[] categories = readLongs(in, max); + long[] notePrints = readLongs(in, max); + long[] taskPrints = readLongs(in, max); + long[] tagPrints = readLongs(in, max); + long[] categoryPrints = readLongs(in, max); + // Without a fingerprint per id nothing could tell a record changed since the + // hand-off, so a damaged file offers no clear at all. + if (notes.length != notePrints.length + || tasks.length != taskPrints.length + || tags.length != tagPrints.length + || categories.length != categoryPrints.length) { + return null; + } + return new EnclyMigrationRepository.Exported( + notes, + tasks, + tags, + categories, + notePrints, + taskPrints, + tagPrints, + categoryPrints); + } catch (IOException | RuntimeException e) { + return null; + } + } + + /** + * Deletes the snapshots saved before {@code requestedAt}, in milliseconds since the epoch; pass + * {@link Long#MAX_VALUE} to delete every one. + * + * @return how many were deleted. + */ + public static synchronized int sweep(@NonNull File dir, long requestedAt) { + File[] files = dir.listFiles(); + if (files == null) return 0; + int deleted = 0; + for (File file : files) { + String name = file.getName(); + if (!name.startsWith(PREFIX)) continue; + if (madeAt(name) < requestedAt && file.delete()) deleted++; + } + return deleted; + } + + private static File fileFor(File dir, String token) { + return new File(dir, PREFIX + token + SUFFIX); + } + + private static boolean isToken(String token) { + return token.matches("\\d+-[0-9a-f]+"); + } + + /** When the file was made, from its name; a name that does not say counts as long ago. */ + private static long madeAt(String name) { + int start = PREFIX.length(); + int end = name.indexOf('-', start); + if (end < 0) return Long.MIN_VALUE; + try { + return Long.parseLong(name.substring(start, end)); + } catch (NumberFormatException e) { + return Long.MIN_VALUE; + } + } + + private static void writeLongs(DataOutputStream out, long[] values) throws IOException { + out.writeInt(values.length); + for (long value : values) out.writeLong(value); + } + + private static long[] readLongs(DataInputStream in, long max) throws IOException { + int length = in.readInt(); + if (length < 0 || length > max) throw new IOException("Bad length " + length); + long[] values = new long[length]; + for (int i = 0; i < length; i++) values[i] = in.readLong(); + return values; + } +} diff --git a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java index d2b58153..b7a5089f 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java +++ b/app/src/main/java/com/pasich/mynotes/utils/file/FileExportUtils.java @@ -10,6 +10,8 @@ import android.net.Uri; import android.util.Log; import android.widget.Toast; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import androidx.core.content.FileProvider; import com.pasich.mynotes.R; import com.pasich.mynotes.data.model.Note; @@ -18,6 +20,7 @@ import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; @@ -144,113 +147,112 @@ public static void shareViaOtherApps(Context context, String noteTitle, String n context.startActivity(chooser); } - /** Save TXT content to selected URI */ - public static void saveTxtToUri( + /** + * Saves TXT content to the selected URI. Runs on a background thread and shows nothing; returns + * whether the file was written. + */ + @WorkerThread + public static boolean saveTxtToUri( Context context, Uri uri, String noteTitle, String noteContent) { try { String formattedContent = formatNoteContent(noteTitle, noteContent); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri); - - if (outputStream != null) { - // Add UTF-8 BOM for better compatibility - byte[] bom = new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; - outputStream.write(bom); - - byte[] contentBytes = formattedContent.getBytes(StandardCharsets.UTF_8); + return writeUtf8WithBom(context, uri, formattedContent); + } catch (Exception e) { + Log.e(TAG, "Error saving TXT file", e); + return false; + } + } - outputStream.write(contentBytes); - outputStream.flush(); - outputStream.close(); + /** Measures how wide a piece of text is drawn. */ + interface TextWidth { + float of(String text); + } - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); - } else { - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); + private static final int PDF_TOP = 50; + + /** Lowest baseline a body line may be drawn at before the next page starts. */ + private static final int PDF_BOTTOM = 800; + + // A4 in points. + private static final int PDF_PAGE_WIDTH = 595; + private static final int PDF_PAGE_HEIGHT = 842; + private static final int PDF_LEFT = 50; + private static final int PDF_LINE_HEIGHT = 20; + private static final int PDF_TEXT_WIDTH = 500; + + /** + * Writes text as UTF-8 behind a byte-order mark, closing the stream even when a write fails. + * + * @return false when the document could not be opened for writing. + */ + private static boolean writeUtf8WithBom(Context context, Uri uri, String text) + throws java.io.IOException { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(uri)) { + if (outputStream == null) { + return false; } - } catch (Exception e) { - Log.e(TAG, "Error saving TXT file", e); - Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) - .show(); + // Add UTF-8 BOM for better compatibility + outputStream.write(new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); + outputStream.write(text.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + return true; } } - /** Save PDF content to selected URI */ - public static void savePdfToUri( + /** + * Saves PDF content to the selected URI. Runs on a background thread and shows nothing; returns + * whether the file was written. + */ + @WorkerThread + public static boolean savePdfToUri( Context context, Uri uri, String noteTitle, String noteContent) { + PdfDocument pdfDocument = new PdfDocument(); try { - PdfDocument pdfDocument = new PdfDocument(); PdfDocument.PageInfo pageInfo = - new PdfDocument.PageInfo.Builder(595, 842, 1).create(); // A4 size + new PdfDocument.PageInfo.Builder(PDF_PAGE_WIDTH, PDF_PAGE_HEIGHT, 1).create(); PdfDocument.Page page = pdfDocument.startPage(pageInfo); Canvas canvas = page.getCanvas(); Paint paint = new Paint(); paint.setColor(Color.BLACK); - paint.setTextSize(12); paint.setAntiAlias(true); - int y = 50; - int lineHeight = 20; - int maxWidth = 500; + int y = PDF_TOP; String title = (noteTitle == null || noteTitle.trim().isEmpty()) ? "***" : noteTitle.trim(); paint.setTextSize(16); paint.setFakeBoldText(true); - canvas.drawText(title, 50, y, paint); - y += 30; // Extra space after title + for (String titleLine : wrapLine(title, PDF_TEXT_WIDTH, paint::measureText)) { + canvas.drawText(titleLine, PDF_LEFT, y, paint); + y += PDF_LINE_HEIGHT; + } + y += 10; // Extra space after title paint.setTextSize(12); paint.setFakeBoldText(false); if (noteContent != null && !noteContent.trim().isEmpty()) { - String[] lines = noteContent.trim().split("\n"); - - for (String line : lines) { - if (y > 800) { // Start new page if needed - pdfDocument.finishPage(page); - pageInfo = - new PdfDocument.PageInfo.Builder( - 595, 842, pdfDocument.getPages().size() + 1) - .create(); - page = pdfDocument.startPage(pageInfo); - canvas = page.getCanvas(); - y = 50; - } - - // Handle line wrapping - if (paint.measureText(line) > maxWidth) { - String[] words = line.split(" "); - StringBuilder currentLine = new StringBuilder(); - - for (String word : words) { - if (paint.measureText(currentLine + word + " ") > maxWidth) { - if (currentLine.length() > 0) { - canvas.drawText(currentLine.toString(), 50, y, paint); - y += lineHeight; - currentLine = new StringBuilder(word + " "); - } - } else { - currentLine.append(word).append(" "); - } + for (String line : noteContent.trim().split("\n")) { + // The page break is checked per drawn line, not per paragraph: a long + // paragraph wraps to many lines and ran off the bottom of the page. + for (String drawn : wrapLine(line, PDF_TEXT_WIDTH, paint::measureText)) { + if (y > PDF_BOTTOM) { + pdfDocument.finishPage(page); + pageInfo = + new PdfDocument.PageInfo.Builder( + PDF_PAGE_WIDTH, + PDF_PAGE_HEIGHT, + pdfDocument.getPages().size() + 1) + .create(); + page = pdfDocument.startPage(pageInfo); + canvas = page.getCanvas(); + y = PDF_TOP; } - - if (currentLine.length() > 0) { - canvas.drawText(currentLine.toString(), 50, y, paint); - y += lineHeight; - } - } else { - canvas.drawText(line, 50, y, paint); - y += lineHeight; + canvas.drawText(drawn, PDF_LEFT, y, paint); + y += PDF_LINE_HEIGHT; } } } else { @@ -259,74 +261,129 @@ public static void savePdfToUri( pdfDocument.finishPage(page); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri); - Log.d(TAG, "PDF OutputStream opened: " + (outputStream != null)); - - if (outputStream != null) { + try (OutputStream outputStream = context.getContentResolver().openOutputStream(uri)) { + if (outputStream == null) { + Log.e(TAG, "PDF OutputStream is null"); + return false; + } pdfDocument.writeTo(outputStream); outputStream.flush(); - outputStream.close(); - - Log.d(TAG, "PDF written successfully"); - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); - } else { - Log.e(TAG, "PDF OutputStream is null"); - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); } - + return true; + } catch (Exception e) { + Log.e(TAG, "Error saving PDF file", e); + return false; + } finally { pdfDocument.close(); + } + } - } catch (Exception e) { - Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) - .show(); + /** + * Splits one source line into the lines drawn for it, none wider than {@code maxWidth}. + * + *

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

The line's leading whitespace is its indent (a nested list item, a code block), and every + * line drawn for it starts with it. Splitting on spaces used to turn the indent into empty + * words that the first line swallowed, so a wrapped line lost its indent while a short one kept + * it. An indent wider than half the line is cut to half, so some text always fits beside it. + */ + @VisibleForTesting + static List wrapLine(String line, float maxWidth, TextWidth width) { + List out = new ArrayList<>(); + if (width.of(line) <= maxWidth) { + out.add(line); + return out; + } + int bodyStart = 0; + while (bodyStart < line.length() + && (line.charAt(bodyStart) == ' ' || line.charAt(bodyStart) == '\t')) { + bodyStart++; + } + String indent = line.substring(0, bodyStart); + while (!indent.isEmpty() && width.of(indent) > maxWidth / 2) { + indent = indent.substring(0, indent.length() - 1); + } + float bodyWidth = maxWidth - width.of(indent); + for (String drawn : wrapWords(line.substring(bodyStart), bodyWidth, width)) { + out.add(indent + drawn); + } + if (out.isEmpty()) { + out.add(indent); + } + return out; + } + + /** Breaks text that starts with no whitespace into lines no wider than {@code maxWidth}. */ + private static List wrapWords(String text, float maxWidth, TextWidth width) { + List out = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + for (String word : text.split(" ", -1)) { + String candidate = current.length() == 0 ? word : current + " " + word; + if (width.of(candidate) <= maxWidth) { + current.setLength(0); + current.append(candidate); + continue; + } + if (current.length() > 0) { + out.add(current.toString()); + current.setLength(0); + } + while (width.of(word) > maxWidth) { + int cut = fittingPrefix(word, maxWidth, width); + out.add(word.substring(0, cut)); + word = word.substring(cut); + } + current.append(word); + } + if (current.length() > 0) { + out.add(current.toString()); + } + return out; + } + + /** + * The length of the longest prefix of {@code word} that fits, never splitting a surrogate pair + * and never less than one character, so the caller always makes progress. + */ + private static int fittingPrefix(String word, float maxWidth, TextWidth width) { + int low = word.offsetByCodePoints(0, 1); + int high = word.length(); + // Invariant: the prefix of length low is taken (it fits, or is the forced minimum). + while (low < high) { + int mid = (low + high + 1) >>> 1; + if (Character.isHighSurrogate(word.charAt(mid - 1))) { + mid--; // the prefix would end between the two halves of one character + } + if (mid <= low) { + break; + } + if (width.of(word.substring(0, mid)) <= maxWidth) { + low = mid; + } else { + high = mid - 1; + } } + return low; } - /** Save HTML content to selected URI */ - public static void saveHtmlToUri( + /** + * Saves HTML content to the selected URI. Runs on a background thread and shows nothing; + * returns whether the file was written. + */ + @WorkerThread + public static boolean saveHtmlToUri( Context context, Uri uri, String noteTitle, String noteContent, List notes) { try { String htmlContent = HtmlTemplateGenerator.generateHtmlContent( context, noteTitle, noteContent, notes); - OutputStream outputStream = context.getContentResolver().openOutputStream(uri); - - if (outputStream != null) { - // Add UTF-8 BOM for better compatibility - byte[] bom = new byte[] {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}; - outputStream.write(bom); - - byte[] contentBytes = htmlContent.getBytes(StandardCharsets.UTF_8); - - outputStream.write(contentBytes); - outputStream.flush(); - outputStream.close(); - - Toast.makeText( - context, - context.getString(R.string.fileSavedSuccessfully), - Toast.LENGTH_SHORT) - .show(); - } else { - Toast.makeText( - context, - context.getString(R.string.errorSavingFile), - Toast.LENGTH_SHORT) - .show(); - } + return writeUtf8WithBom(context, uri, htmlContent); } catch (Exception e) { Log.e(TAG, "Error saving HTML file", e); - Toast.makeText(context, context.getString(R.string.errorSavingFile), Toast.LENGTH_SHORT) - .show(); + return false; } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java b/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java index 275de8ef..932cbd61 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java +++ b/app/src/main/java/com/pasich/mynotes/utils/file/ImageOptimizer.java @@ -2,25 +2,45 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Matrix; import android.os.Build; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.exifinterface.media.ExifInterface; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; /** - * Ultra-lightweight image optimizer for Editor.js attachments. + * Lightweight image optimizer for Editor.js attachments. * - *

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

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

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

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

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

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

PNG with alpha → stays PNG. PNG without alpha → JPEG. JPEG stays JPEG. WEBP stays WEBP * (lossy if Android R+). * - * @return optimized image bytes or original bytes on error. + * @return optimized image bytes, or the original bytes on any error, running out of memory + * included. */ public static byte[] optimizeRawImage( byte[] raw, OutFormat inputFormat, boolean extraOptimize) { + Bitmap bitmap = null; try { // We skip HEIC, GIF, PDF, DOC, ZIP — everything UNKNOWN if (inputFormat == OutFormat.UNKNOWN) { @@ -60,12 +82,35 @@ public static byte[] optimizeRawImage( int quality = extraOptimize ? 60 : 85; + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(raw, 0, raw.length, bounds); + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return raw; + BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + opts.inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, MAX_DIMENSION); - Bitmap bitmap = BitmapFactory.decodeByteArray(raw, 0, raw.length, opts); + bitmap = BitmapFactory.decodeByteArray(raw, 0, raw.length, opts); if (bitmap == null) return raw; + Matrix orientation = orientationMatrix(readOrientation(raw)); + if (orientation != null) { + Bitmap upright = + Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.getWidth(), + bitmap.getHeight(), + orientation, + true); + if (upright != bitmap) { + bitmap.recycle(); + bitmap = upright; + } + } + OutFormat outFormat; if (inputFormat == OutFormat.PNG) { @@ -100,14 +145,73 @@ public static byte[] optimizeRawImage( } ByteArrayOutputStream bos = new ByteArrayOutputStream(); - bitmap.compress(fmt, quality, bos); - bitmap.recycle(); + if (!bitmap.compress(fmt, quality, bos)) return raw; return bos.toByteArray(); + } catch (Exception | OutOfMemoryError e) { + // Running out of memory is recoverable here: the bitmap is dropped and the original + // bytes, which are already in memory, are stored as they came. + Log.w(TAG, "Keeping the original image; it could not be optimized", e); + return raw; + } finally { + if (bitmap != null) { + bitmap.recycle(); + } + } + } + + /** + * The smallest power-of-two subsample (the only kind the decoder honours) that brings the long + * side to at most {@code maxDimension}. + */ + @VisibleForTesting + static int sampleSizeFor(int width, int height, int maxDimension) { + int longSide = Math.max(width, height); + int sample = 1; + while ((longSide + sample - 1) / sample > maxDimension) { + sample *= 2; + } + return sample; + } + + private static int readOrientation(@NonNull byte[] raw) { + try { + return new ExifInterface(new ByteArrayInputStream(raw)) + .getAttributeInt( + ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); } catch (Exception e) { - return raw; // fallback + return ExifInterface.ORIENTATION_NORMAL; + } + } + + /** + * The transform that turns a picture stored with this EXIF orientation upright, or {@code null} + * when it already is. + */ + @VisibleForTesting + @Nullable + static Matrix orientationMatrix(int orientation) { + Matrix matrix = new Matrix(); + switch (orientation) { + case ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1, 1); + case ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180); + case ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1, -1); + case ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90); + matrix.postScale(-1, 1); + } + case ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90); + case ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90); + matrix.postScale(-1, 1); + } + case ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90); + default -> { + return null; + } } + return matrix; } /** Allowed output formats */ diff --git a/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java b/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java index 3a6d1cfd..833cbf76 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java +++ b/app/src/main/java/com/pasich/mynotes/utils/managers/BillingManager.java @@ -2,12 +2,16 @@ import android.app.Activity; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import com.android.billingclient.api.*; import com.pasich.mynotes.data.model.DonationProduct; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** Manages in-app billing for donation products via Google Play Billing. */ public class BillingManager implements PurchasesUpdatedListener, BillingClientStateListener { @@ -31,6 +35,7 @@ public interface BillingManagerListener { private final BillingClient billingClient; private final BillingManagerListener listener; private final ThankedPurchases thankedPurchases; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); // Product IDs for donations private static final List DONATION_PRODUCT_IDS = @@ -63,15 +68,21 @@ private void startServiceConnection() { @Override public void onBillingSetupFinished(BillingResult billingResult) { + onMain(() -> handleSetupFinished(billingResult)); + } + + private void handleSetupFinished(BillingResult billingResult) { if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { Log.d(TAG, "Billing service connected successfully"); // Спочатку споживаємо всі наявні покупки - consumeAllExistingPurchases(() -> { - // The listener queries purchases itself once billing is initialized. - listener.onBillingInitialized(); - queryProductDetails(); - }); + consumeSettledPurchases( + null, + () -> { + // The listener queries purchases itself once billing is initialized. + listener.onBillingInitialized(); + queryProductDetails(); + }); } else { Log.e(TAG, "Failed to connect to billing service: " + billingResult.getDebugMessage()); listener.onBillingError("Failed to connect to billing service"); @@ -79,59 +90,90 @@ public void onBillingSetupFinished(BillingResult billingResult) { } /** - * Consumes every purchase left from earlier sessions. Purchases the user was never thanked for - * (completed while the app was closed, or pending ones that settled in the background) get a - * single thank-you for the whole batch once they are consumed. + * Play Billing may answer on a thread of its own. Every answer is handled on the main thread, + * so the counters below and the record of thanked purchases are only ever touched there. + */ + private void onMain(Runnable work) { + if (Looper.myLooper() == Looper.getMainLooper()) work.run(); + else mainHandler.post(work); + } + + /** + * Consumes the settled purchases left over (every one, or only those of {@code productId}), + * then runs {@code onComplete}. The one place purchases are consumed outside the purchase flow: + * at start, before a new purchase and after ITEM_ALREADY_OWNED. + * + *

Purchases the user was never thanked for (completed while the app was closed, or pending + * ones that settled in the background) get a single thank-you for the whole batch once they are + * consumed. Whether a purchase was thanked is read when its consume finishes, so a purchase the + * purchase flow thanks meanwhile is not thanked twice. */ - private void consumeAllExistingPurchases(Runnable onComplete) { + private void consumeSettledPurchases(String productId, Runnable onComplete) { QueryPurchasesParams params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build(); + // Taken before the query is sent: its answer can only vouch for tokens recorded by then. + Set recordedAtQuery = productId == null ? thankedPurchases.snapshot() : null; billingClient.queryPurchasesAsync( params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) { - Log.e( - TAG, - "Failed to query existing purchases for consumption: " - + billingResult.getDebugMessage()); - onComplete.run(); - return; - } - List settled = new ArrayList<>(); - for (Purchase purchase : purchasesList) { - if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { - settled.add(purchase); - } - } - if (settled.isEmpty()) { - Log.d(TAG, "No existing purchases to consume"); - onComplete.run(); - return; - } - - Log.d(TAG, "Found " + settled.size() + " existing purchases, consuming them"); - int[] remaining = {settled.size()}; - String[] unthankedProduct = {null}; - for (Purchase purchase : settled) { - boolean thanked = thankedPurchases.wasThanked(purchase.getPurchaseToken()); - consumePurchase( - purchase, - consumed -> { - if (consumed && !thanked && !purchase.getProducts().isEmpty()) { - unthankedProduct[0] = purchase.getProducts().get(0); - } - if (--remaining[0] == 0) { - if (unthankedProduct[0] != null) { - listener.onPurchaseSuccessful(unthankedProduct[0]); - } - onComplete.run(); - } - }); - } - }); + (billingResult, purchasesList) -> + onMain(() -> { + if (billingResult.getResponseCode() + != BillingClient.BillingResponseCode.OK) { + Log.e( + TAG, + "Failed to query existing purchases: " + + billingResult.getDebugMessage()); + onComplete.run(); + return; + } + if (recordedAtQuery != null) { + // Tokens of purchases that are gone (refunded, revoked) are + // dropped from the record; one thanked after the query was sent + // is not in this answer and is kept. + Set owned = new HashSet<>(); + for (Purchase purchase : purchasesList) { + owned.add(purchase.getPurchaseToken()); + } + thankedPurchases.retainOnly(owned, recordedAtQuery); + } + List settled = new ArrayList<>(); + for (Purchase purchase : purchasesList) { + if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED + && (productId == null + || purchase.getProducts().contains(productId))) { + settled.add(purchase); + } + } + if (settled.isEmpty()) { + onComplete.run(); + return; + } + + Log.d(TAG, "Consuming " + settled.size() + " existing purchases"); + int[] remaining = {settled.size()}; + String[] unthankedProduct = {null}; + for (Purchase purchase : settled) { + consumePurchase( + purchase, + (consumed, wasThanked) -> { + if (consumed + && !wasThanked + && !purchase.getProducts().isEmpty()) { + unthankedProduct[0] = purchase.getProducts().get(0); + } + if (--remaining[0] == 0) { + if (unthankedProduct[0] != null) { + listener.onPurchaseSuccessful( + unthankedProduct[0]); + } + onComplete.run(); + } + }); + } + })); } @Override @@ -155,26 +197,28 @@ private void queryProductDetails() { billingClient.queryProductDetailsAsync( queryProductDetailsParams, - (billingResult, productDetailsList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - List products = new ArrayList<>(); - for (ProductDetails productDetails : - productDetailsList.getProductDetailsList()) { - DonationProduct product = createDonationProduct(productDetails); - products.add(product); - } - - products.sort(DonationProduct.BY_PRICE); - - listener.onProductsLoaded(products); - } else { - Log.e( - TAG, - "Failed to query product details: " - + billingResult.getDebugMessage()); - listener.onBillingError("Failed to load products"); - } - }); + (billingResult, productDetailsList) -> + onMain(() -> { + if (billingResult.getResponseCode() + == BillingClient.BillingResponseCode.OK) { + List products = new ArrayList<>(); + for (ProductDetails productDetails : + productDetailsList.getProductDetailsList()) { + DonationProduct product = createDonationProduct(productDetails); + products.add(product); + } + + products.sort(DonationProduct.BY_PRICE); + + listener.onProductsLoaded(products); + } else { + Log.e( + TAG, + "Failed to query product details: " + + billingResult.getDebugMessage()); + listener.onBillingError("Failed to load products"); + } + })); } private DonationProduct createDonationProduct(ProductDetails productDetails) { @@ -209,53 +253,7 @@ private String getIconForProduct(String productId) { /** Initiates the purchase flow for the given product, consuming any stale purchase first. */ public void launchBillingFlow(Activity activity, String productId) { - // Спочатку перевіряємо і споживаємо наявні покупки цього товару - queryAndConsumeExistingPurchases( - productId, - () -> { - // Після споживання запускаємо новий billing flow - proceedWithBillingFlow(activity, productId); - }); - } - - private void queryAndConsumeExistingPurchases(String productId, Runnable onComplete) { - QueryPurchasesParams params = - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) - .build(); - - billingClient.queryPurchasesAsync( - params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - boolean foundExisting = false; - for (Purchase purchase : purchasesList) { - if (purchase.getProducts().contains(productId) - && purchase.getPurchaseState() - == Purchase.PurchaseState.PURCHASED) { - foundExisting = true; - Log.d( - TAG, - "Found existing purchase for " - + productId - + ", consuming it first"); - consumePurchase(purchase, consumed -> onComplete.run()); - break; - } - } - if (!foundExisting) { - // Немає наявних покупок, можемо продовжувати - onComplete.run(); - } - } else { - Log.e( - TAG, - "Failed to query existing purchases: " - + billingResult.getDebugMessage()); - // Пробуємо продовжити навіть при помилці - onComplete.run(); - } - }); + consumeSettledPurchases(productId, () -> proceedWithBillingFlow(activity, productId)); } private void proceedWithBillingFlow(Activity activity, String productId) { @@ -271,41 +269,63 @@ private void proceedWithBillingFlow(Activity activity, String productId) { billingClient.queryProductDetailsAsync( queryProductDetailsParams, - (billingResult, productDetailsList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK - && !productDetailsList.getProductDetailsList().isEmpty()) { - - ProductDetails productDetails = - productDetailsList.getProductDetailsList().get(0); - List productDetailsParamsList = - List.of( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .build()); - - BillingFlowParams billingFlowParams = - BillingFlowParams.newBuilder() - .setProductDetailsParamsList(productDetailsParamsList) - .build(); - - BillingResult result = - billingClient.launchBillingFlow(activity, billingFlowParams); - if (result.getResponseCode() != BillingClient.BillingResponseCode.OK) { - Log.e( - TAG, - "Failed to launch billing flow: " + result.getDebugMessage()); - listener.onPurchaseFailed( - result.getResponseCode(), result.getDebugMessage()); - } - } else { - Log.e(TAG, "Product not found: " + productId); - listener.onBillingError("Product not found"); - } - }); + (billingResult, productDetailsList) -> + onMain(() -> { + if (billingResult.getResponseCode() + != BillingClient.BillingResponseCode.OK) { + // Billing unavailable, disconnected, no network: say which, rather + // than + // claiming the product does not exist. + Log.e( + TAG, + "Failed to load product: " + + billingResult.getDebugMessage()); + listener.onPurchaseFailed( + billingResult.getResponseCode(), + billingResult.getDebugMessage()); + } else if (!productDetailsList.getProductDetailsList().isEmpty()) { + + ProductDetails productDetails = + productDetailsList.getProductDetailsList().get(0); + List + productDetailsParamsList = + List.of( + BillingFlowParams.ProductDetailsParams + .newBuilder() + .setProductDetails(productDetails) + .build()); + + BillingFlowParams billingFlowParams = + BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + productDetailsParamsList) + .build(); + + BillingResult result = + billingClient.launchBillingFlow( + activity, billingFlowParams); + if (result.getResponseCode() + != BillingClient.BillingResponseCode.OK) { + Log.e( + TAG, + "Failed to launch billing flow: " + + result.getDebugMessage()); + listener.onPurchaseFailed( + result.getResponseCode(), result.getDebugMessage()); + } + } else { + Log.e(TAG, "Product not found: " + productId); + listener.onBillingError("Product not found"); + } + })); } @Override public void onPurchasesUpdated(BillingResult billingResult, List purchases) { + onMain(() -> handlePurchasesUpdated(billingResult, purchases)); + } + + private void handlePurchasesUpdated(BillingResult billingResult, List purchases) { if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) { for (Purchase purchase : purchases) { @@ -319,7 +339,7 @@ public void onPurchasesUpdated(BillingResult billingResult, List purch == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) { Log.d(TAG, "Item already owned, attempting to consume existing purchase"); // Спробуємо знайти і спожити наявну покупку - queryAndConsumeAllPurchases(); + consumeSettledPurchases(null, () -> {}); listener.onBillingError("Item already owned. Please try again in a moment."); } else { Log.e(TAG, "Purchase failed: " + billingResult.getDebugMessage()); @@ -328,25 +348,6 @@ public void onPurchasesUpdated(BillingResult billingResult, List purch } } - private void queryAndConsumeAllPurchases() { - QueryPurchasesParams params = - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) - .build(); - - billingClient.queryPurchasesAsync( - params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - for (Purchase purchase : purchasesList) { - if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { - consumePurchase(purchase, consumed -> {}); - } - } - } - }); - } - private void handlePurchase(Purchase purchase) { if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { // Get the first product ID from the purchase @@ -358,14 +359,14 @@ private void handlePurchase(Purchase purchase) { thankedPurchases.markThanked(purchase.getPurchaseToken()); // Consume the purchase immediately to allow repeated purchases - consumePurchase(purchase, consumed -> {}); + consumePurchase(purchase, (consumed, wasThanked) -> {}); } } } - /** Receives whether a consume succeeded; it is called whatever the result. */ + /** Receives whether a consume succeeded, and whether the purchase had been thanked for. */ private interface ConsumeCallback { - void onConsumeFinished(boolean consumed); + void onConsumeFinished(boolean consumed, boolean wasThanked); } private void consumePurchase(Purchase purchase, ConsumeCallback callback) { @@ -374,24 +375,30 @@ private void consumePurchase(Purchase purchase, ConsumeCallback callback) { billingClient.consumeAsync( consumeParams, - (billingResult, purchaseToken) -> { - boolean consumed = - billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK; - if (consumed) { - Log.d(TAG, "Purchase consumed successfully, can be purchased again"); - // A consumed purchase never comes back, so its thanks need no record. - thankedPurchases.forget(purchase.getPurchaseToken()); - } else { - Log.e( - TAG, - "Failed to consume purchase: " + billingResult.getDebugMessage()); - } - callback.onConsumeFinished(consumed); - }); + (billingResult, purchaseToken) -> + onMain(() -> { + boolean consumed = + billingResult.getResponseCode() + == BillingClient.BillingResponseCode.OK; + boolean wasThanked = + thankedPurchases.wasThanked(purchase.getPurchaseToken()); + if (consumed) { + Log.d(TAG, "Purchase consumed, it can be bought again"); + // A consumed purchase never comes back: its record goes. + thankedPurchases.forget(purchase.getPurchaseToken()); + } else { + Log.e( + TAG, + "Failed to consume purchase: " + + billingResult.getDebugMessage()); + } + callback.onConsumeFinished(consumed, wasThanked); + })); } /** Ends the billing client connection; call from Activity/Fragment onDestroy. */ public void destroy() { + mainHandler.removeCallbacksAndMessages(null); // Unconditional: with automatic service reconnection a dropped client is not ready but // still live, and would reconnect for a call still in flight. billingClient.endConnection(); @@ -434,13 +441,18 @@ public void queryPurchases() { billingClient.queryPurchasesAsync( params, - (billingResult, purchasesList) -> { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - listener.onPurchasesLoaded(purchasesList); - } else { - Log.e(TAG, "Failed to query purchases: " + billingResult.getDebugMessage()); - listener.onPurchasesLoaded(new ArrayList<>()); - } - }); + (billingResult, purchasesList) -> + onMain(() -> { + if (billingResult.getResponseCode() + == BillingClient.BillingResponseCode.OK) { + listener.onPurchasesLoaded(purchasesList); + } else { + Log.e( + TAG, + "Failed to query purchases: " + + billingResult.getDebugMessage()); + listener.onPurchasesLoaded(new ArrayList<>()); + } + })); } } diff --git a/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java b/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java index 9d27d8d1..da5568ab 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java +++ b/app/src/main/java/com/pasich/mynotes/utils/managers/ThankedPurchases.java @@ -25,7 +25,7 @@ final class ThankedPurchases { } boolean wasThanked(String purchaseToken) { - return tokens().contains(purchaseToken); + return prefs.getStringSet(KEY_TOKENS, Set.of()).contains(purchaseToken); } void markThanked(String purchaseToken) { @@ -33,6 +33,29 @@ void markThanked(String purchaseToken) { if (tokens.add(purchaseToken)) save(tokens); } + /** The tokens recorded right now; pass it to {@link #retainOnly} with a query sent after it. */ + Set snapshot() { + return tokens(); + } + + /** + * Drops the tokens of purchases that no longer exist. A purchase refunded or revoked before its + * consume never comes back to be forgotten. + * + *

Only tokens that were already recorded when the purchases query was sent ({@code + * recordedAtQuery}) can be judged by its answer. A purchase thanked while the query was in + * flight is missing from that answer although it exists, and dropping its token got it thanked + * a second time after a failed consume. + */ + void retainOnly(Set ownedTokens, Set recordedAtQuery) { + Set tokens = tokens(); + boolean changed = false; + for (String token : recordedAtQuery) { + if (!ownedTokens.contains(token)) changed |= tokens.remove(token); + } + if (changed) save(tokens); + } + void forget(String purchaseToken) { Set tokens = tokens(); if (tokens.remove(purchaseToken)) save(tokens); diff --git a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java index 12998f94..ea6194f5 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java +++ b/app/src/main/java/com/pasich/mynotes/utils/reminder/ReminderRescheduler.java @@ -8,6 +8,7 @@ import com.pasich.mynotes.data.model.RepeatRule; import com.pasich.mynotes.data.model.Task; import dagger.hilt.android.qualifiers.ApplicationContext; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import javax.inject.Inject; @@ -75,11 +76,26 @@ public synchronized Result rescheduleAll(long now) { if (!ReminderManager.armSnooze( context, snooze.noteId, snooze.time, snooze.intervalMinutes)) inexact++; } - List tasks = database.taskDao().getTasksWithRemindersSync(); + List tasks = new ArrayList<>(); + for (Task task : database.taskDao().getTasksWithRemindersSync()) { + if (shouldArm(task, now)) tasks.add(task); + } TaskReminderManager.rescheduleAll(context, tasks); return new Result(armed, inexact); } + /** + * Same rule as for notes: a reminder repeating every N minutes keeps going, a one-time one + * missed by more than {@link #MISSED_ONE_TIME_GRACE_MS} is left alone. + */ + static boolean shouldArm(@NonNull Task task, long now) { + Long time = task.getReminderTime(); + if (time == null || task.isDone()) return false; + if (time > now) return true; + if (task.getReminderIntervalMinutes() > 0) return true; + return now - time <= MISSED_ONE_TIME_GRACE_MS; + } + /** * Arms or cancels the given notes' reminders to match the database, after sync wrote them: a * reminder set, moved or turned off on another device, or a note deleted there. diff --git a/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java b/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java index b6c9b1b4..9ebe559c 100644 --- a/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java +++ b/app/src/main/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessor.java @@ -3,9 +3,13 @@ import android.content.ContentResolver; import android.content.Intent; import android.net.Uri; +import androidx.annotation.Nullable; import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Locale; public class ShareProcessor { @@ -25,30 +29,64 @@ public static String extractSharedText(Intent intent) { return text != null ? text : ""; } + /** + * Whether a file another app asks to open as a note may be read. The handler is exported, so + * any app can send it any URI: only another app's content is read. A {@code file://} URI or one + * of this app's own providers would copy the app's private files into a note the sender could + * then get back. + * + * @param packageName this app's package name; its providers' authorities start with it. + */ + public static boolean isReadableSharedUri(@Nullable Uri uri, String packageName) { + if (uri == null || !ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) { + return false; + } + String authority = uri.getAuthority(); + if (authority == null || authority.isEmpty()) return false; + String own = packageName.toLowerCase(Locale.ROOT); + // An authority may list several, separated by semicolons. + for (String one : authority.toLowerCase(Locale.ROOT).split(";")) { + String name = one.contains("@") ? one.substring(one.lastIndexOf('@') + 1) : one; + if (name.equals(own) || name.startsWith(own + ".")) return false; + } + return true; + } + + /** + * Reads the text at {@code uri} on a background thread. Any failure, a provider that refuses or + * returns nothing included, reaches {@code callback.onError} instead of crashing the thread. + */ public static void readFileAsync(ContentResolver resolver, Uri uri, FileReadCallback callback) { new Thread( () -> { + String text; try { - StringBuilder sb = new StringBuilder(); - try (BufferedReader reader = - new BufferedReader( - new InputStreamReader( - resolver.openInputStream(uri)))) { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - - if (sb.length() > MAX_SHARE_SIZE) break; - } - } - callback.onSuccess(sb.toString()); - } catch (IOException e) { + text = readText(resolver, uri); + } catch (Exception e) { callback.onError(e); + return; } + callback.onSuccess(text); }) .start(); } + private static String readText(ContentResolver resolver, Uri uri) throws IOException { + InputStream in = resolver.openInputStream(uri); + if (in == null) throw new IOException("No content for " + uri); + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + + if (sb.length() > MAX_SHARE_SIZE) break; + } + } + return sb.toString(); + } + public static boolean isTooLarge(String text) { if (text == null) return false; return text.getBytes().length > MAX_SHARE_SIZE; @@ -57,6 +95,6 @@ public static boolean isTooLarge(String text) { public interface FileReadCallback { void onSuccess(String content); - void onError(IOException error); + void onError(Exception error); } } diff --git a/app/src/main/res/layout/activity_note.xml b/app/src/main/res/layout/activity_note.xml index 95afa3ac..b89d5c52 100644 --- a/app/src/main/res/layout/activity_note.xml +++ b/app/src/main/res/layout/activity_note.xml @@ -220,6 +220,7 @@ Выбраць рэз. копію Аднавіць з аблокі Аднаўленне дадзеных \nПасля завяршэння дадзенае акно будзе аўтаматычна зачынена. + Іншае аднаўленне яшчэ выконваецца. Выберыце рэзервовую копію зноў, калі яно завершыцца. + Іншая рэзервовая копія яшчэ захоўваецца. Захавайце зноў, калі яна завершыцца. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ef28e731..c8ef0e83 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -84,6 +84,8 @@ Auflösung auswählen. kopieren Aus Cloud wiederherstellen Daten wiederherstellen \nDieses Fenster wird nach Abschluss automatisch geschlossen. + Eine andere Wiederherstellung läuft noch. Wähle die Sicherung erneut aus, sobald sie abgeschlossen ist. + Eine andere Sicherung wird noch gespeichert. Speichere erneut, sobald sie abgeschlossen ist. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 152d90b7..63a599d2 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -105,6 +105,8 @@ Select backup Restore from cloud Restoring data\nThis window will close automatically when finished. + Another restore is still running. Pick the backup again once it has finished. + Another backup is still being saved. Save again once it has finished. No data to back up Backup created successfully diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f0c09fcd..0f174143 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -94,6 +94,8 @@ Seleccione Restaurar. copiar Restaurar desde la nube Restaurando datos \nEsta ventana se cerrará automáticamente cuando se complete. + Otra restauración sigue en curso. Vuelve a elegir la copia de seguridad cuando termine. + Otra copia de seguridad se sigue guardando. Vuelve a guardar cuando termine. No hay datos para respaldar diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e63d659f..51476892 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -90,6 +90,8 @@ Sélectionner res. copier Restauration à partir du cloud Restaurer les données \nCette fenêtre se fermera automatiquement une fois terminée. + Une autre restauration est encore en cours. Sélectionnez à nouveau la sauvegarde une fois celle-ci terminée. + Une autre sauvegarde est encore en cours d\'enregistrement. Réessayez une fois celle-ci terminée. Il n\'y a pas de données à sauvegarder diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 28cf66a7..33c29bb2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -89,6 +89,8 @@ Seleziona res. copia Ripristina dal cloud Ripristina dati \nQuesta finestra si chiuderà automaticamente al termine. + Un altro ripristino è ancora in corso. Seleziona di nuovo il backup al termine. + Un altro backup è ancora in fase di salvataggio. Salva di nuovo al termine. Non ci sono dati di cui eseguire il backup diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index 02e35d50..51e25550 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -91,6 +91,8 @@ Қалпына келтіру опциясын таңдаңыз. көшіру Бұлттан қалпына келтіру Деректерді қалпына келтіру \nБұл терезе аяқталғаннан кейін автоматты түрде жабылады. + Басқа қалпына келтіру әлі орындалуда. Ол аяқталғаннан кейін сақтық көшірмені қайта таңдаңыз. + Басқа сақтық көшірме әлі сақталуда. Ол аяқталғаннан кейін қайта сақтаңыз. Сақтық көшірме жасау үшін деректер жоқ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 2a3d154d..5f345196 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -92,6 +92,8 @@ Wybierz opcję Przywróć. skopiuj Przywróć z chmury Przywracanie danych \nTo okno zamknie się automatycznie po zakończeniu. + Inne przywracanie wciąż trwa. Wybierz kopię zapasową ponownie, gdy się zakończy. + Inna kopia zapasowa jest wciąż zapisywana. Zapisz ponownie, gdy się zakończy. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fb92f0c6..4a2370f4 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -94,6 +94,8 @@ Выбрать рез. копию Восстановить из облака Восстановление данных \nПо завершении это окно закроется автоматически. + Другое восстановление ещё выполняется. Выберите резервную копию снова, когда оно завершится. + Другая резервная копия ещё сохраняется. Сохраните снова, когда это завершится. Нет данных, для создания резервной копии diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d54d659a..f7febe55 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -91,6 +91,8 @@ Вибрати рез. копію Відновити з хмари Відновлення даних \nПісля завершення це вікно буде автоматично закрито. + Інше відновлення ще триває. Виберіть резервну копію знову, коли його буде завершено. + Інша резервна копія ще зберігається. Збережіть знову, коли це завершиться. Немає даних для створення резервної копії diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index dbe45ab7..6690e89f 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -2,6 +2,5 @@ 3 100 - 20000 10 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d18644f..5424be39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -107,6 +107,8 @@ Select Restore. copy Restore from Cloud Restoring data \nThis window will close automatically when completed. + Another restore is still running. Pick the backup again once it finishes. + Another backup is still being saved. Save again once it finishes. No data to backup diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java index 896485c1..8a4f292a 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/GoogleDriveSyncBackendTest.java @@ -938,6 +938,37 @@ public void oneSyncListsTheRootFoldersOnce() throws Exception { assertThat(server.folderListings() - before).isEqualTo(1); } + @Test + public void existenceAndVerificationShareOneListingOfTheBlob() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + backend().writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + GoogleDriveSyncBackend backend = backend(); + int before = server.listingsMentioning(hash); + + assertThat(backend.hasAttachment(hash)).isTrue(); + assertThat(backend.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + + // The service asks both questions for every attachment on every sync; each used to list + // the root again. + assertThat(server.listingsMentioning(hash) - before).isEqualTo(1); + } + + @Test + public void aBlobUploadedAfterAListingIsSeenByTheNextQuestion() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + byte[] other = "other".getBytes(StandardCharsets.UTF_8); + backend().writeAttachment(sha256(other), other.length, new ByteArrayInputStream(other)); + GoogleDriveSyncBackend backend = backend(); + // The blob is listed, and found absent, before this same backend uploads it. + assertThat(backend.hasAttachment(hash)).isFalse(); + + backend.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + + assertThat(backend.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + } + @Test public void anOversizedCandidateIsSkippedRatherThanFailingTheSync() throws Exception { // A Drive object larger than the ceiling and tagged with the expected hash: the ceiling @@ -1277,6 +1308,7 @@ private static final class FakeDriveServer implements AutoCloseable { private volatile boolean running = true; private volatile CyclicBarrier emptyRootListingBarrier; private final AtomicInteger folderListings = new AtomicInteger(); + private final List listingQueries = new ArrayList<>(); private final Map mediaReads = new ConcurrentHashMap<>(); private final List deletedFileIds = java.util.Collections.synchronizedList(new ArrayList<>()); @@ -1359,6 +1391,17 @@ int folderListings() { return folderListings.get(); } + /** How many file listings named {@code text} in their query. */ + int listingsMentioning(String text) { + int total = 0; + synchronized (listingQueries) { + for (String query : listingQueries) { + if (query.contains(text)) total++; + } + } + return total; + } + /** How many times the bytes of the blob carrying {@code sha256} were downloaded. */ int mediaReadsOfAttachment(String sha256) { int total = 0; @@ -1682,6 +1725,11 @@ && ownedFolderCount() == 0) { && query.contains("mimeType = 'application/vnd.google-apps.folder'")) { folderListings.incrementAndGet(); } + if (query != null) { + synchronized (listingQueries) { + listingQueries.add(query); + } + } JsonArray array = new JsonArray(); for (DriveFile file : files.values()) { if (matchesQuery(file, query)) { diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java new file mode 100644 index 00000000..04ecec59 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreAttachmentVerificationTest.java @@ -0,0 +1,110 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import androidx.room.Room; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Verifying a local blob goes through the digest cache rather than re-hashing it every sync. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreAttachmentVerificationTest { + + private AppDatabase db; + private RoomSyncStore store; + private final int[] hashes = {0}; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + store = + new RoomSyncStore( + context, + db, + mock(PreferenceHelper.class), + (ignored, attachment) -> null, + file -> { + hashes[0]++; + return Sha256.of(file); + }); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void anUnchangedBlobIsHashedOnceAcrossRepeatedVerifications() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = Sha256.of(bytes); + store.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + + assertThat(store.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + assertThat(store.hasVerifiedAttachment(hash, (long) bytes.length)).isTrue(); + assertThat(store.hasVerifiedAttachment(hash, null)).isTrue(); + + assertThat(hashes[0]).isEqualTo(1); + } + + @Test + public void manyVerificationsWriteTheDigestCacheOnceAtTheEnd() throws Exception { + File cacheFile = + new File( + new File( + RuntimeEnvironment.getApplication().getFilesDir(), + "sync-attachments"), + "hash-cache.json"); + java.util.List written = new java.util.ArrayList<>(); + for (int i = 0; i < 5; i++) { + byte[] bytes = ("photo " + i).getBytes(StandardCharsets.UTF_8); + String hash = Sha256.of(bytes); + store.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + written.add(hash); + } + cacheFile.delete(); + + for (String hash : written) { + assertThat(store.hasVerifiedAttachment(hash, null)).isTrue(); + // Rewriting the whole cache after each one was quadratic on a cold cache. + assertThat(cacheFile.exists()).isFalse(); + } + store.finishAttachmentVerification(); + + assertThat(cacheFile.isFile()).isTrue(); + String persisted = + new String( + java.nio.file.Files.readAllBytes(cacheFile.toPath()), + StandardCharsets.UTF_8); + for (String hash : written) { + assertThat(persisted).contains(hash); + } + } + + @Test + public void aBlobOfTheWrongSizeOrMissingIsNotVerified() throws Exception { + byte[] bytes = "photo".getBytes(StandardCharsets.UTF_8); + String hash = Sha256.of(bytes); + store.writeAttachment(hash, bytes.length, new ByteArrayInputStream(bytes)); + + assertThat(store.hasVerifiedAttachment(hash, (long) bytes.length + 1)).isFalse(); + assertThat(store.hasVerifiedAttachment(Sha256.of("absent".getBytes()), null)).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java new file mode 100644 index 00000000..80626d62 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/RoomSyncStoreStaleEditTest.java @@ -0,0 +1,193 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; + +import android.content.Context; +import androidx.room.Room; +import com.google.gson.JsonObject; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.entities.SyncMetadataEntity; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.preferences.PreferenceHelper; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Collections; +import java.util.UUID; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * An edit saved while a sync is in flight survives the apply that sync ends with. + * + *

The apply skips a record that moved on locally after the build read it. Two things defeated + * that guard: the build paired content read first with a timestamp read afterwards, and the guard + * compared against the merged record's timestamp, which is another device's clock. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class RoomSyncStoreStaleEditTest { + + private static final String NOTE_ID = "11111111-1111-4111-8111-111111111111"; + private static final String ATTACHMENT_ID = "66666666-6666-4666-8666-666666666666"; + + private AppDatabase db; + private Context context; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void anEditSavedWhileTheBuildReadsTheNoteIsNotRevertedByTheApply() throws Exception { + File blob = File.createTempFile("attachment", ".bin"); + blob.deleteOnExit(); + Files.write(blob.toPath(), "blob".getBytes(StandardCharsets.UTF_8)); + int noteId = + seedNote( + "Shopping", + "[{\"id\":\"" + + ATTACHMENT_ID + + "\",\"url\":\"file:///notes/a.bin\",\"name\":\"a\"," + + "\"extension\":\"bin\",\"size\":4}]"); + boolean[] edited = {false}; + RoomSyncStore store = + new RoomSyncStore( + context, + db, + mock(PreferenceHelper.class), + (ignored, attachment) -> blob, + file -> { + // The editor saves after the build read the note's row but before it + // finished describing the note. + if (!edited[0]) { + edited[0] = true; + saveEdit(noteId, "Edited meanwhile", 2_000L); + } + return Sha256.of(file); + }); + + SyncSnapshot built = store.readSnapshot(); + // Nothing on the remote side: the merge result is the build itself. + store.applySnapshot(built, Collections.emptyList()); + + assertThat(edited[0]).isTrue(); + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Edited meanwhile"); + } + + @Test + public void aLocalEditDuringSyncSurvivesARemoteVersionFromAClockAhead() throws Exception { + int noteId = seedNote("Shopping", null); + RoomSyncStore store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + SyncSnapshot built = store.readSnapshot(); + + saveEdit(noteId, "Local edit", 2_000L); + // The remote device's clock runs well ahead of this one. + store.applySnapshot( + new SyncSnapshot( + Collections.singletonList( + renamed(built, "Remote", Instant.ofEpochMilli(50_000L)))), + Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Local edit"); + } + + @Test + public void anUntouchedNoteTakesANewerRemoteVersionFromAClockBehind() throws Exception { + int noteId = seedNote("Shopping", null); + RoomSyncStore store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + SyncSnapshot built = store.readSnapshot(); + + // The merge chose the remote version; its device's clock runs behind this one. + store.applySnapshot( + new SyncSnapshot( + Collections.singletonList( + renamed(built, "Remote", Instant.ofEpochMilli(500L)))), + Collections.emptyList()); + + assertThat(db.noteDao().getNoteSync(noteId).getTitle()).isEqualTo("Remote"); + } + + /** The metadata is re-read in batches; records on every side of a batch boundary are kept. */ + @Test + public void aSnapshotSpanningSeveralMetadataBatchesKeepsEveryRecord() throws Exception { + int count = 1_201; + for (int i = 0; i < count; i++) { + Note note = new Note().create("Note " + i, "Body", 1_000L, ""); + int id = db.noteDao().addNote(note).intValue(); + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, + id, + UUID.nameUUIDFromBytes(("note-" + i).getBytes()).toString(), + 1_000L + i, + null)); + } + RoomSyncStore store = new RoomSyncStore(context, db, mock(PreferenceHelper.class)); + + SyncSnapshot built = store.readSnapshot(); + + int notes = 0; + for (int i = 0; i < count; i++) { + SyncRecord record = + built.find( + SyncRecord.Type.NOTE, + UUID.nameUUIDFromBytes(("note-" + i).getBytes()).toString()); + assertThat(record).isNotNull(); + assertThat(record.getUpdatedAt().toEpochMilli()).isEqualTo(1_000L + i); + assertThat(record.getPayload().get("b").getAsString()).isEqualTo("Note " + i); + notes++; + } + assertThat(notes).isEqualTo(count); + } + + private void saveEdit(int noteId, String title, long at) { + Note stored = db.noteDao().getNoteSync(noteId); + db.noteDao() + .updateNoteContent( + noteId, + title, + stored.getValue(), + stored.getValueJson(), + stored.getDate(), + stored.getTag(), + stored.getAttachments()); + db.syncMetadataDao().touch(SyncMetadata.RECORD_TYPE_NOTE, noteId, at); + } + + private static SyncRecord renamed(SyncSnapshot snapshot, String title, Instant updatedAt) { + SyncRecord note = snapshot.find(SyncRecord.Type.NOTE, NOTE_ID); + JsonObject payload = note.getPayload().deepCopy(); + payload.addProperty("b", title); + return SyncRecord.live(SyncRecord.Type.NOTE, NOTE_ID, updatedAt, payload); + } + + private int seedNote(String title, String attachmentsJson) { + Note note = new Note().create(title, "Milk", 1_000L, ""); + note.setAttachments(attachmentsJson); + int id = db.noteDao().addNote(note).intValue(); + db.syncMetadataDao() + .insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, id, NOTE_ID, 1_000L, null)); + return id; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java new file mode 100644 index 00000000..f98ca130 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SettledConflictRetentionTest.java @@ -0,0 +1,143 @@ +package com.pasich.mynotes.data.sync; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import androidx.room.Room; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.database.dao.SyncConflictDao; +import com.pasich.mynotes.data.database.entities.SyncConflictEntity; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** + * Settled conflicts are bounded by age, not by whatever order SQLite happens to return them in. + * + *

The bundle carries a capped number of settled versions. The query feeding that cap was a plain + * UNION, so which versions survived it was arbitrary, and settled rows were never deleted. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SettledConflictRetentionTest { + + private AppDatabase db; + private SyncConflictDao dao; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + dao = db.syncConflictDao(); + } + + @After + public void tearDown() { + db.close(); + } + + @Test + public void resolvedVersionIdsComeNewestFirst() { + // Inserted in an order unrelated to their settlement time or their ids. + insertResolved("b", 200L); + insertResolved("c", 300L); + insertResolved("a", 100L); + insertOpen("z"); + + List ids = dao.getResolvedVersionIds(); + + assertThat(ids).hasSize(6); + assertThat(ids.subList(0, 2)).containsExactly(id("c", 'w'), id("c", 'l')); + assertThat(ids.subList(2, 4)).containsExactly(id("b", 'w'), id("b", 'l')); + assertThat(ids.subList(4, 6)).containsExactly(id("a", 'w'), id("a", 'l')); + } + + @Test + public void deleteResolvedBeyondKeepsOnlyTheNewestSettlementsAndEveryOpenConflict() { + insertResolved("a", 100L); + insertResolved("b", 200L); + insertResolved("c", 300L); + insertOpen("z"); + + dao.deleteResolvedBeyond(2); + + assertThat(dao.getResolvedVersionIds()) + .containsExactly(id("c", 'w'), id("c", 'l'), id("b", 'w'), id("b", 'l')); + assertThat(dao.getUnresolvedCount()).isEqualTo(1); + } + + @Test + public void capSettledVersionIdsDropsTheOldestLocalSettlements() { + List newestFirst = new ArrayList<>(); + for (int index = 0; index < SyncService.MAX_PUBLISHED_SETTLED_IDS + 50; index++) { + newestFirst.add(String.format("%064x", index)); + } + + Set kept = SyncService.capSettledVersionIds(newestFirst, Collections.emptyList()); + + assertThat(kept) + .containsExactlyElementsIn( + newestFirst.subList(0, SyncService.MAX_PUBLISHED_SETTLED_IDS)); + } + + @Test + public void capSettledVersionIdsKeepsSomeOfEachSide() { + List local = new ArrayList<>(); + List remote = new ArrayList<>(); + for (int index = 0; index < SyncService.MAX_PUBLISHED_SETTLED_IDS; index++) { + local.add(String.format("a%063x", index)); + remote.add(String.format("b%063x", index)); + } + + Set kept = SyncService.capSettledVersionIds(local, remote); + + assertThat(kept).hasSize(SyncService.MAX_PUBLISHED_SETTLED_IDS); + assertThat(kept).contains(local.get(0)); + assertThat(kept).contains(remote.get(0)); + assertThat(kept).doesNotContain(local.get(local.size() - 1)); + } + + private void insertResolved(String name, long resolvedAt) { + dao.insertIgnoringDuplicates(Collections.singletonList(row(name, true, resolvedAt))); + } + + private void insertOpen(String name) { + dao.insertIgnoringDuplicates(Collections.singletonList(row(name, false, 0L))); + } + + private static String id(String name, char side) { + return name + side; + } + + private static SyncConflictEntity row(String name, boolean resolved, long resolvedAt) { + return new SyncConflictEntity( + "note", + "stable-" + name, + "pair-" + name, + "LOCAL", + "REMOTE", + id(name, 'w'), + id(name, 'l'), + "{}", + "{}", + 1L, + 1L, + false, + false, + resolved ? "KEEP_WINNER" : "PENDING", + resolved, + 1L, + resolvedAt); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java index 38bee94a..6dc88829 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncMutationCoordinatorTest.java @@ -45,6 +45,13 @@ public void setUp() { taskCategoryDao = mock(TaskCategoryDao.class); transactions = mock(Transactions.class); syncMetadataDao = new FakeSyncMetadataDao(); + // Every note an edit names exists unless a test says otherwise. + when(noteDao.getNoteSync(org.mockito.ArgumentMatchers.anyInt())) + .thenAnswer(call -> { + Note stored = new Note().create("Stored", "", 1L, ""); + stored.setId(call.getArgument(0)); + return stored; + }); coordinator = new SyncMutationCoordinator( new SyncMutationCoordinator.TransactionExecutor() { @@ -205,6 +212,75 @@ public void updateNoteContent_reusesExistingStableIdAndTouchesMetadata() { assertThat(metadata.deletedAt).isNull(); } + @Test + public void updateNoteContent_leavesTheTombstoneOfANoteDeletedMeanwhile() { + // Another device's deletion was applied by a sync while the editor was open. The content + // UPDATE matches no row, and touching the metadata anyway cleared the tombstone: a live + // record with no note behind it, which then also outranked the remote deletion forever. + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 7L, "seed-stable", 900L, 900L)); + when(noteDao.getNoteSync(7)).thenReturn(null); + Note note = new Note().create("Late edit", "Body", 10L, ""); + note.setId(7); + + boolean stored = coordinator.updateNoteContent(note); + + assertThat(stored).isFalse(); + verify(noteDao, never()) + .updateNoteContent( + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + SyncMetadataEntity metadata = syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 7L); + assertThat(metadata.updatedAt).isEqualTo(900L); + assertThat(metadata.deletedAt).isEqualTo(900L); + } + + @Test + public void reviveNote_reinsertsUnderItsIdAndOutranksTheTombstone() { + // The deletion came from another device whose clock is ahead: the tombstone is stamped + // later than this device's now. The revived record must still be the newer one, under the + // stable id the other devices know, or the next sync would delete the edit again. + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 7L, "seed-stable", 5_000L, 5_000L)); + when(noteDao.getNoteSync(7)).thenReturn(null); + Note note = new Note().create("Late edit", "Body", 10L, ""); + note.setId(7); + + coordinator.reviveNote(note); + + verify(noteDao).addNote(note); + assertThat(note.getId()).isEqualTo(7); + SyncMetadataEntity metadata = syncMetadataDao.get(SyncMetadata.RECORD_TYPE_NOTE, 7L); + assertThat(metadata.stableId).isEqualTo("seed-stable"); + assertThat(metadata.updatedAt).isGreaterThan(5_000L); + assertThat(metadata.deletedAt).isNull(); + } + + @Test + public void reviveNote_ofANoteThatExists_updatesItInPlace() { + syncMetadataDao.insertIfAbsent( + new SyncMetadataEntity( + SyncMetadata.RECORD_TYPE_NOTE, 7L, "seed-stable", 80L, null)); + Note note = new Note().create("Edit", "Body", 10L, ""); + note.setId(7); + note.setValueJson("{}"); + note.setAttachments("[]"); + + coordinator.reviveNote(note); + + verify(noteDao, never()).addNote(org.mockito.ArgumentMatchers.any()); + verify(noteDao) + .updateNoteContent( + eq(7), eq("Edit"), eq("Body"), eq("{}"), eq(10L), eq(""), eq("[]")); + } + @Test public void deleteNote_keepsTombstoneAndMarksDeletionTimestamp() { syncMetadataDao.insertIfAbsent( @@ -709,6 +785,16 @@ public List getExistingLocalIds(String recordType, List localIds) { return existing; } + @Override + public List getByLocalIds(String recordType, List localIds) { + List found = new ArrayList<>(); + for (Long localId : localIds) { + SyncMetadataEntity row = get(recordType, localId); + if (row != null) found.add(row); + } + return found; + } + @Override public SyncMetadataEntity getByStableId(String recordType, String stableId) { for (SyncMetadataEntity value : rows.values()) { diff --git a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java index bbeae446..d8e9e5ea 100644 --- a/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java +++ b/app/src/test/java/com/pasich/mynotes/data/sync/SyncServiceTest.java @@ -156,6 +156,132 @@ public void sync_skipsUploadingAttachmentWhenRemoteBlobAlreadyExists() throws Ex .inOrder(); } + @Test + public void sync_trustsTheStoresDigestCacheInsteadOfReHashingEveryLocalBlob() throws Exception { + SyncRecord local = note(TEN, "Local with an unchanged attachment"); + byte[] bytes = "local attachment".getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + FakeStore store = new FakeStore(snapshot(local)); + store.attachmentHashes = Collections.singletonList(hash); + store.attachments.put(hash, bytes); + store.verifiedFromCache.add(hash); + FakeBackend backend = new FakeBackend(SyncSnapshot.empty()); + backend.attachments.put(hash, bytes); + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + // Every local blob used to be read in full and hashed on every sync, cache or not. + assertThat(store.readAttachmentCalls).isEqualTo(0); + } + + @Test + public void sync_persistsTheStoresDigestCacheOnceForAllAttachments() throws Exception { + List hashes = new ArrayList<>(); + FakeStore store = new FakeStore(snapshot(note(TEN, "Local with attachments"))); + FakeBackend backend = new FakeBackend(SyncSnapshot.empty()); + for (int i = 0; i < 3; i++) { + byte[] bytes = ("attachment " + i).getBytes(StandardCharsets.UTF_8); + String hash = sha256(bytes); + hashes.add(hash); + store.attachments.put(hash, bytes); + store.verifiedFromCache.add(hash); + backend.attachments.put(hash, bytes); + } + store.attachmentHashes = hashes; + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + assertThat(store.finishAttachmentVerificationCalls).isEqualTo(1); + } + + @Test + public void sync_stopsPublishingADeletionOnceItIsPastTheHorizon() { + // Every tombstone used to go into every bundle for good, and the schema caps a bundle's + // records, so enough deletions over a library's life failed every publish permanently. + SyncRecord old = tombstone(NOTE_ID, daysBeforeNow(100)); + SyncRecord recent = tombstone(OTHER_NOTE_ID, daysBeforeNow(10)); + FakeStore store = new FakeStore(snapshot(old, recent)); + FakeBackend backend = new FakeBackend(SyncSnapshot.empty()); + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + assertThat(backend.snapshot.getRecords()).containsExactly(recent); + // The device itself still holds and applies the deletion. + assertThat(store.appliedSnapshot.getRecords()).containsExactly(old, recent); + + // And the omission is not mistaken for news on the next sync. + new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + assertThat(backend.writeSnapshotCalls).isEqualTo(1); + } + + @Test + public void sync_doesNotRepublishWhenRemoteOnlyAddsBackExpiredTombstones() { + // An older app version on another device keeps every tombstone and puts the expired ones + // back into each bundle it writes. Comparing the pruned snapshot with that bundle made + // both devices republish everything on every sync, forever. + SyncRecord old = tombstone(NOTE_ID, daysBeforeNow(100)); + SyncRecord recent = tombstone(OTHER_NOTE_ID, daysBeforeNow(10)); + FakeStore store = new FakeStore(snapshot(old, recent)); + FakeBackend backend = new FakeBackend(snapshot(old, recent)); + + SyncState state = new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(state.getStatus()).isEqualTo(SyncState.Status.SUCCESS); + assertThat(backend.writeSnapshotCalls).isEqualTo(0); + assertThat(store.appliedSnapshot.getRecords()).containsExactly(old, recent); + } + + @Test + public void sync_stillPublishesWhenRemoteLacksAnUnexpiredTombstone() { + SyncRecord old = tombstone(NOTE_ID, daysBeforeNow(100)); + SyncRecord recent = tombstone(OTHER_NOTE_ID, daysBeforeNow(10)); + FakeStore store = new FakeStore(snapshot(old, recent)); + FakeBackend backend = new FakeBackend(snapshot(old)); + + new SyncService(store, new SyncMerger(), CLOCK).sync(backend); + + assertThat(backend.writeSnapshotCalls).isEqualTo(1); + assertThat(backend.snapshot.getRecords()).containsExactly(recent); + } + + @Test + public void sync_answersAStaleCopyOfAnOldDeletionWithTheTombstone() { + // A device that slept through the horizon never saw the deletion, and once Drive stopped + // carrying the tombstone it republished its live copy. The next device still holding the + // tombstone has to put the deletion back rather than take the copy. + SyncRecord staleCopy = note(daysBeforeNow(101), "Deleted elsewhere long ago"); + SyncRecord deleted = tombstone(NOTE_ID, daysBeforeNow(100)); + FakeStore holder = new FakeStore(snapshot(deleted)); + holder.syncedVersions.put(key(deleted), deleted.getCanonicalPayloadHash()); + FakeBackend backend = new FakeBackend(snapshot(staleCopy)); + + new SyncService(holder, new SyncMerger(), CLOCK).sync(backend); + + assertThat(versions(backend.snapshot)).containsExactly(deleted.getCanonicalPayloadHash()); + assertThat(versions(holder.appliedSnapshot)) + .containsExactly(deleted.getCanonicalPayloadHash()); + + // The stale device now receives the deletion instead of keeping its copy. + FakeStore stale = new FakeStore(snapshot(staleCopy)); + stale.syncedVersions.put(key(staleCopy), staleCopy.getCanonicalPayloadHash()); + new SyncService(stale, new SyncMerger(), CLOCK).sync(backend); + + assertThat(versions(stale.appliedSnapshot)) + .containsExactly(deleted.getCanonicalPayloadHash()); + // Settled everywhere, nobody republishes the bundle just to drop the expired tombstone: it + // leaves Drive with the next publish that has news, and until then it is harmless. + int publishes = backend.writeSnapshotCalls; + new SyncService(stale, new SyncMerger(), CLOCK).sync(backend); + assertThat(backend.writeSnapshotCalls).isEqualTo(publishes); + new SyncService(holder, new SyncMerger(), CLOCK).sync(backend); + assertThat(backend.writeSnapshotCalls).isEqualTo(publishes); + assertThat(versions(holder.appliedSnapshot)) + .containsExactly(deleted.getCanonicalPayloadHash()); + } + @Test public void sync_repairsCorruptLocalAttachmentFromRemote() throws Exception { SyncRecord local = note(TEN, "Local with corrupt attachment"); @@ -440,6 +566,28 @@ public void sync_namesTheNoteWhenABlobNoEndpointHoldsWasPublishedFromMemory() th assertThat(backend.writeSnapshotCalls).isEqualTo(0); } + private static final String OTHER_NOTE_ID = "550e8400-e29b-41d4-a716-446655440001"; + + private static Instant daysBeforeNow(long days) { + return TWENTY.minus(java.time.Duration.ofDays(days)); + } + + private static SyncRecord tombstone(String id, Instant deletedAt) { + return SyncRecord.tombstone(SyncRecord.Type.NOTE, id, deletedAt, deletedAt); + } + + private static List versions(SyncSnapshot snapshot) { + List versions = new ArrayList<>(); + for (SyncRecord record : snapshot.getRecords()) { + versions.add(record.getCanonicalPayloadHash()); + } + return versions; + } + + private static String key(SyncRecord record) { + return record.getType() + ":" + record.getId(); + } + private static SyncSnapshot snapshot(SyncRecord... records) { return new SyncSnapshot(Arrays.asList(records)); } @@ -596,8 +744,27 @@ public boolean hasAttachment(String sha256) { return attachments.containsKey(sha256); } + /** Blobs the store vouches for from its digest cache, as RoomSyncStore does. */ + private final java.util.Set verifiedFromCache = new java.util.HashSet<>(); + + private int finishAttachmentVerificationCalls; + + @Override + public void finishAttachmentVerification() { + finishAttachmentVerificationCalls++; + } + + private int readAttachmentCalls; + + @Override + public boolean hasVerifiedAttachment(String sha256, Long expectedSize) throws IOException { + return verifiedFromCache.contains(sha256) + || SyncStore.super.hasVerifiedAttachment(sha256, expectedSize); + } + @Override public InputStream readAttachment(String sha256) throws IOException { + readAttachmentCalls++; byte[] bytes = attachments.get(sha256); if (bytes == null) { throw new IOException("missing local attachment"); diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java index c3b3fe6e..39233230 100644 --- a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceFinishTest.java @@ -110,6 +110,65 @@ public void aFlushPostedBeforeTheWebViewWent_isDroppedWhenItRuns() throws Except verify(webView, never()).evaluateJavascript(any(), any()); } + @Test + public void aFlushWaitsForThePageAndRunsAfterTheFlushedDocument() { + StringBuilder order = new StringBuilder(); + EditorJSInterface.EditorListener listener = mock(EditorJSInterface.EditorListener.class); + org.mockito.Mockito.doAnswer( + invocation -> { + // As the activity does: handled on the main thread, posted from the + // bridge. + new android.os.Handler(Looper.getMainLooper()) + .post(() -> order.append("document;")); + return null; + }) + .when(listener) + .onContentFlushed(any()); + EditorJSInterface page = + new EditorJSInterface( + listener, + webView, + RuntimeEnvironment.getApplication(), + new SettingsEditorJsBridge(false)); + + page.flush(() -> order.append("close;")); + verify(webView).evaluateJavascript(contains("onFlushFinished(1)"), any()); + idleFor(10); + assertThat(order.toString()).isEmpty(); + + // The page sends the document, then reports the flush finished. + page.onContentFlushed("[]"); + page.onFlushFinished(1); + idleFor(0); + assertThat(order.toString()).isEqualTo("document;close;"); + + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS * 2); + assertThat(order.toString()).isEqualTo("document;close;"); + } + + @Test + public void aFlushThePageNeverAnswers_runsAfterTheTimeout() { + AtomicInteger closed = new AtomicInteger(); + bridge.flush(closed::incrementAndGet); + + idleFor(EditorJSInterface.FINISH_TIMEOUT_MS - 10); + assertThat(closed.get()).isEqualTo(0); + idleFor(20); + assertThat(closed.get()).isEqualTo(1); + } + + @Test + public void aFlushStillWaiting_runsWhenTheWebViewGoes() { + AtomicInteger closed = new AtomicInteger(); + bridge.flush(closed::incrementAndGet); + + bridge.release(); + assertThat(closed.get()).isEqualTo(1); + + bridge.flush(closed::incrementAndGet); + assertThat(closed.get()).isEqualTo(2); + } + @Test public void finishingAnAlreadyDestroyedPage_runsAtOnce() { bridge.release(); diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java new file mode 100644 index 00000000..e1eaa94e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/EditorJSInterfaceScriptTest.java @@ -0,0 +1,41 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.webkit.WebView; +import com.pasich.mynotes.extendedEditor.models.SettingsEditorJsBridge; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** Values from a note's document reach the page's scripts as string literals, never as code. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class EditorJSInterfaceScriptTest { + + @Test + public void aBlockIdAndUrlCannotBreakOutOfTheirStrings() { + WebView webView = mock(WebView.class); + EditorJSInterface bridge = + new EditorJSInterface( + mock(EditorJSInterface.EditorListener.class), + webView, + RuntimeEnvironment.getApplication(), + new SettingsEditorJsBridge(false)); + + bridge.deleteAttachmentBlockRequest("a');Android.x();('", "editorjs://a\\'b"); + + ArgumentCaptor script = ArgumentCaptor.forClass(String.class); + verify(webView).evaluateJavascript(script.capture(), any()); + assertThat(script.getValue()) + .isEqualTo( + "deleteAttachmentBlockFromAndroid(\"a');Android.x();('\"," + + " \"editorjs:\\/\\/a\\\\'b\");"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java new file mode 100644 index 00000000..6bb10e13 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/extendedEditor/utils/PlainTextBlocksRoundTripTest.java @@ -0,0 +1,73 @@ +package com.pasich.mynotes.extendedEditor.utils; + +import static com.google.common.truth.Truth.assertThat; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * The extended editor opens a plain-text note with a paragraph block per line, blank lines going in + * front of the next line as {@code
} (plainTextBlocks in notes_editor/src/custom/runtime.js). + * Turned back into plain text, those blocks must give exactly the note that went in, so switching + * editors never changes a note. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class PlainTextBlocksRoundTripTest { + + private static JSONObject paragraph(String html) throws Exception { + return new JSONObject() + .put("type", "paragraph") + .put("data", new JSONObject().put("text", html)); + } + + @Test + public void aBlockPerLineGivesBackTheSameText() throws Exception { + String text = "first line\n\nsecond paragraph\nthird line\n \n\nlast"; + JSONArray blocks = + new JSONArray() + .put(paragraph("first line")) + .put(paragraph("
second paragraph")) + .put(paragraph("third line")) + .put(paragraph("

last")); + + String plain = EditorJsonUtils.extendedNoteToOldNote(blocks.toString()).plainText; + + assertThat(plain).isEqualTo(text); + } + + /** + * The page escapes markup in a plain-text note (plainTextBlocks), so it is shown as text; the + * way back decodes it again, for paragraphs and headers alike. + */ + @Test + public void escapedMarkupComesBackAsTyped() throws Exception { + String text = "List a && b\n\nx > 1 & y\nTitle

"; + JSONArray blocks = + new JSONArray() + .put(paragraph("List<String> a && b")) + .put(paragraph("
x > 1 &amp; y")) + .put( + new JSONObject() + .put("type", "header") + .put( + "data", + new JSONObject() + .put("text", "Title <h1>") + .put("level", 2))); + + String plain = EditorJsonUtils.extendedNoteToOldNote(blocks.toString()).plainText; + + assertThat(plain).isEqualTo(text); + } + + @Test + public void nonBreakingSpacesAndNumericReferencesAreDecoded() { + assertThat(EditorJsonUtils.cleanText("a b cA'")).isEqualTo("a b cA'"); + assertThat(EditorJsonUtils.cleanText("&unknown; & &#xZZ;")).isEqualTo("&unknown; & &#xZZ;"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java new file mode 100644 index 00000000..71fb017e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/BackupPresenterRestoreTest.java @@ -0,0 +1,249 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.net.Uri; +import com.google.gson.Gson; +import com.pasich.mynotes.R; +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.ui.contract.BackupContract; +import com.pasich.mynotes.ui.presenter.BackupPresenter; +import com.pasich.mynotes.utils.backup.models.googleKeep.GoogleKeepImportResult; +import com.pasich.mynotes.utils.backup.models.googleKeep.GoogleKeepNote; +import com.pasich.mynotes.utils.constants.CloudErrors; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Test; + +/** A restore is not cut short by the screen that started it going away. */ +public class BackupPresenterRestoreTest extends BasePresenterTest { + + private final TestScheduler io = new TestScheduler(); + private DataManager dataManager; + + @Before + public void setUp() { + dataManager = mock(DataManager.class); + when(dataManager.getNotes()).thenReturn(Flowable.just(new ArrayList<>())); + when(dataManager.getNotesInTrash()).thenReturn(Flowable.just(new ArrayList<>())); + when(dataManager.getTagsUser()).thenReturn(Flowable.just(new ArrayList<>())); + when(dataManager.addNotes(anyList())).thenReturn(Completable.complete()); + when(dataManager.addTags(anyList())).thenReturn(Completable.complete()); + } + + private SchedulerProvider schedulers() { + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler io() { + return io; + } + }; + } + + private BackupPresenter presenter(BackupContract.view view) { + return presenter(view, null); + } + + /** A screen instance; a non-null token makes it the recreation of the one that saved it. */ + private BackupPresenter presenter(BackupContract.view view, String savedToken) { + BackupPresenter presenter = + new BackupPresenter(schedulers(), new CompositeDisposable(), dataManager); + presenter.attachView(view); + presenter.restoreScreenToken(savedToken); + presenter.viewIsReady(); + return presenter; + } + + private static GoogleKeepImportResult oneKeepNote() { + GoogleKeepNote note = + new Gson() + .fromJson( + "{\"title\":\"t\",\"textContent\":\"body\"}", GoogleKeepNote.class); + return GoogleKeepImportResult.success( + List.of(note), Collections.emptyList(), Collections.emptyList()); + } + + @Test + public void restore_reportsToTheScreenThatIsCurrentWhenItEnds() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view second = mock(BackupContract.view.class); + + BackupPresenter rotatedAway = presenter(first); + rotatedAway.importDataOtherApp(oneKeepNote()); + // A rotation: the old screen goes, a new one comes while the restore is still queued. + rotatedAway.detachView(); + presenter(second); + verify(second).showProcessRestoreDialog(); + + io.triggerActions(); + + verify(dataManager, org.mockito.Mockito.times(2)).addNotes(anyList()); + verify(dataManager).addTags(anyList()); + verify(second).onRestoreSuccessFlag(); + verify(second).restoreFinish(CloudErrors.OKAY_RESTORE); + verify(first, never()).restoreFinish(anyInt()); + } + + @Test + public void restore_finishingWithNoScreen_isReportedToItsRecreation() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view recreated = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.importDataOtherApp(oneKeepNote()); + gone.detachView(); + io.triggerActions(); + + presenter(recreated, token); + + verify(recreated).restoreFinish(CloudErrors.OKAY_RESTORE); + verify(recreated).onRestoreSuccessFlag(); + } + + @Test + public void restore_finishingWithNoScreen_isNotReportedToAnUnrelatedVisit() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view later = mock(BackupContract.view.class); + BackupContract.view recreated = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.importDataOtherApp(oneKeepNote()); + gone.detachView(); + io.triggerActions(); + + // Another instance of the screen does not get it... + BackupPresenter unrelated = presenter(later); + unrelated.detachView(); + verify(later, never()).restoreFinish(anyInt()); + verify(later, never()).onRestoreSuccessFlag(); + // ...and the recreation of the starting screen still does. + presenter(recreated, token); + verify(recreated).restoreFinish(CloudErrors.OKAY_RESTORE); + } + + @Test + public void restore_ofAFinishedScreen_isDroppedNotReportedLater() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view later = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.importDataOtherApp(oneKeepNote()); + gone.onScreenFinished(); + gone.detachView(); + io.triggerActions(); + + presenter(later, token); + + verify(later, never()).restoreFinish(anyInt()); + verify(later, never()).onRestoreSuccessFlag(); + } + + @Test + public void save_finishingWithNoScreen_reachesOnlyItsRecreation() { + BackupContract.view first = mock(BackupContract.view.class); + BackupContract.view later = mock(BackupContract.view.class); + BackupContract.view recreated = mock(BackupContract.view.class); + + BackupPresenter gone = presenter(first); + String token = gone.getScreenToken(); + gone.writeFileBackupLocal(null); + gone.detachView(); + io.triggerActions(); + + presenter(later).detachView(); + verify(later, never()).createLocalCopyFinish(anyBoolean()); + presenter(recreated, token); + verify(recreated).createLocalCopyFinish(anyBoolean()); + } + + @Test + public void pickingASaveTargetWhileASaveRuns_removesTheEmptyFileAndTellsTheUser() { + BackupContract.view view = mock(BackupContract.view.class); + BackupPresenter presenter = presenter(view); + Uri running = mock(Uri.class); + Uri refused = mock(Uri.class); + presenter.writeFileBackupLocal(running); + + presenter.writeFileBackupLocal(refused); + + verify(view).discardUnwrittenBackupFile(refused); + verify(view).showErrorsText(0, R.string.save_already_running); + verify(view, never()).discardUnwrittenBackupFile(running); + io.triggerActions(); + verify(dataManager).writeBackupLocalFile(any(), eq(running)); + verify(dataManager, never()).writeBackupLocalFile(any(), eq(refused)); + verify(view).createLocalCopyFinish(anyBoolean()); + } + + @Test + public void failedRestore_isReportedNotSwallowed() { + when(dataManager.addTags(anyList())) + .thenReturn(Completable.error(new IllegalStateException("disk full"))); + BackupContract.view view = mock(BackupContract.view.class); + + presenter(view).importDataOtherApp(oneKeepNote()); + io.triggerActions(); + + verify(view).restoreFinish(CloudErrors.BACKUP_DESTROY); + verify(view, never()).onRestoreSuccessFlag(); + verify(dataManager, never()).setListPreferences(any()); + } + + @Test + public void pickingABackupWhileARestoreRuns_tellsTheUserInsteadOfDroppingIt() { + BackupContract.view view = mock(BackupContract.view.class); + BackupPresenter presenter = presenter(view); + presenter.importDataOtherApp(oneKeepNote()); + + presenter.readFileBackupLocal(null); + + verify(view).showErrorsText(0, R.string.restore_already_running); + verify(dataManager, never()).readBackupLocalFile(any()); + io.triggerActions(); + verify(view).restoreFinish(CloudErrors.OKAY_RESTORE); + } + + @Test + public void secondKeepImportWhileARestoreRuns_tellsTheUser() { + BackupContract.view view = mock(BackupContract.view.class); + BackupPresenter presenter = presenter(view); + presenter.importDataOtherApp(oneKeepNote()); + + presenter.importDataOtherApp(oneKeepNote()); + + verify(view).showErrorsText(0, R.string.restore_already_running); + io.triggerActions(); + verify(view).restoreFinish(CloudErrors.OKAY_RESTORE); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java index 52924f35..70674f3b 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/MainPresenterTest.java @@ -112,13 +112,16 @@ public void detachView_afterAsyncCallback_doesNotThrowNPE() { private final TestScheduler debounceClock = new TestScheduler(); private BehaviorProcessor> tagsDb; private BehaviorProcessor> notesDb; + private final java.util.concurrent.atomic.AtomicInteger notesQueries = + new java.util.concurrent.atomic.AtomicInteger(); /** Presenter wired to processors standing in for Room, with a controllable debounce clock. */ private MainPresenter syncPresenter(List tags, List notes) { tagsDb = BehaviorProcessor.createDefault(tags); notesDb = BehaviorProcessor.createDefault(notes); when(mockDataManager.getTags()).thenReturn(tagsDb); - when(mockDataManager.getNotes()).thenReturn(notesDb); + when(mockDataManager.getNotes()) + .thenReturn(notesDb.doOnSubscribe(s -> notesQueries.incrementAndGet())); when(mockDataManager.getSortParam()).thenReturn(SortParam.DataSort); SchedulerProvider provider = new SchedulerProvider() { @@ -323,6 +326,36 @@ public void search_ranksExactTitleFirstAfterDebounce() { assertThat(hits.get(0).kind()).isEqualTo(SearchHit.MatchKind.EXACT_TITLE); } + @Test + public void notes_areReadOnceForTheListAndTheSearch() { + syncPresenter(tagsWith(), Collections.emptyList()); + + assertThat(notesQueries.get()).isEqualTo(1); + } + + @Test + public void whileStopped_searchIsNotRerun_andCatchesUpOnStart() { + MainPresenter p = + syncPresenter(tagsWith(), Arrays.asList(new Note().create("PR", "", 10, ""))); + p.updateSearchQuery("PR"); + settle(); + clearInvocations(mockView); + + p.onViewStopped(); + notesDb.onNext( + Arrays.asList( + new Note().create("PR", "", 10, ""), + new Note().create("PR 2", "", 20, ""))); + settle(); + verify(mockView, never()).renderSearch(any(), anyBoolean()); + // The list itself keeps following the database, ready for the return transition. + assertThat(lastRendered().notes()).hasSize(2); + + p.onViewStarted(); + settle(); + assertThat(lastSearch(false)).hasSize(2); + } + @Test public void search_oneCharacterAsksToKeepTyping() { MainPresenter p = diff --git a/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java index 6cf7c1af..992832cb 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/MoreNoteDialogPresenterTest.java @@ -1,13 +1,24 @@ package com.pasich.mynotes.presenter; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.pasich.mynotes.base.BasePresenterTest; import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.ui.contract.dialogs.MoreNoteDialogContract; import com.pasich.mynotes.ui.presenter.dialogs.MoreNoteDialogPresenter; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.TestScheduler; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -45,4 +56,70 @@ public void viewIsReady_textSizeAboveSliderRange_usesMaximumSliderValue() { verify(view).setSliderValue(32); } + + private void loadNote(MoreNoteDialogPresenter target) { + Note note = new Note().create("Title", "Text", 1L, ""); + note.setId(5); + when(dataManager.getNoteForId(5)).thenReturn(Single.just(note)); + target.loadNote(5); + } + + /** A presenter whose io work waits for {@code io} to be triggered. */ + private MoreNoteDialogPresenter presenterWithQueuedIo(TestScheduler io) { + SchedulerProvider immediate = testSchedulerProvider(); + SchedulerProvider queued = + new SchedulerProvider() { + @Override + public Scheduler ui() { + return immediate.ui(); + } + + @Override + public Scheduler computation() { + return immediate.computation(); + } + + @Override + public Scheduler io() { + return io; + } + }; + MoreNoteDialogPresenter target = + new MoreNoteDialogPresenter(queued, new CompositeDisposable(), dataManager); + target.attachView(view); + return target; + } + + @Test + public void togglePin_dialogClosedBeforeTheWriteStarts_stillPins() { + TestScheduler io = new TestScheduler(); + MoreNoteDialogPresenter target = presenterWithQueuedIo(io); + loadNote(target); + io.triggerActions(); + AtomicBoolean ran = new AtomicBoolean(); + when(dataManager.setPinNote(5, true)) + .thenReturn(Completable.fromAction(() -> ran.set(true))); + + target.togglePinNote(); + target.detachView(); // the sheet dismisses in the same click + io.triggerActions(); + + assertTrue(ran.get()); + } + + @Test + public void copy_dialogClosedBeforeTheCopyIsWritten_stillOpensTheCopy() { + TestScheduler io = new TestScheduler(); + MoreNoteDialogPresenter target = presenterWithQueuedIo(io); + loadNote(target); + io.triggerActions(); + when(dataManager.addNote(any())).thenReturn(Single.just(77L)); + AtomicLong opened = new AtomicLong(); + + target.copyNoteMainActivity(opened::set); + target.detachView(); + io.triggerActions(); + + assertEquals(77L, opened.get()); + } } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java new file mode 100644 index 00000000..0370974d --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterDeletedNoteTest.java @@ -0,0 +1,172 @@ +package com.pasich.mynotes.presenter; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.NoteDeletedException; +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.ui.contract.NoteContract; +import com.pasich.mynotes.ui.presenter.NotePresenter; +import com.pasich.mynotes.utils.constants.AutoSave; +import com.pasich.mynotes.utils.enums.SaveState; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +/** + * A note deleted while it is open (a sync applying another device's delete) must not swallow what + * is typed into it: the save fails in the database, and the presenter puts the note back under its + * own id. The id has to stay: the screen's intent, saved state and draft, and the note's attachment + * folder all go by it. + */ +public class NotePresenterDeletedNoteTest extends BasePresenterTest { + + private static final int DELETED_ID = 1; + + @Mock DataManager dataManager; + @Mock NoteContract.view view; + + private final TestScheduler debounceClock = new TestScheduler(); + private final Set existingIds = new HashSet<>(); + private final List written = new ArrayList<>(); + private NotePresenter presenter; + + @Before + public void setUp() { + initMocks(this); + existingIds.add(DELETED_ID); + when(dataManager.updateNote(any())) + .thenAnswer(invocation -> { + Note note = invocation.getArgument(0); + return Completable.fromAction(() -> { + if (!existingIds.contains(note.getId())) { + throw new NoteDeletedException(note.getId()); + } + written.add(note.getId() + ":" + note.getValue()); + }); + }); + when(dataManager.reviveNote(any())) + .thenAnswer(invocation -> { + Note note = invocation.getArgument(0); + return Completable.fromAction(() -> { + existingIds.add(note.getId()); + written.add("revived " + note.getId() + ":" + note.getValue()); + }); + }); + presenter = new NotePresenter(provider(), new CompositeDisposable(), dataManager); + presenter.attachView(view); + + Note stored = new Note().create("Title", "stored text", new Date().getTime(), ""); + stored.setId(DELETED_ID); + stored.setValueJson(""); + stored.setAttachments("[]"); + when(dataManager.getNoteForId(DELETED_ID)).thenReturn(Single.just(stored)); + presenter.setNewNoteKey(false); + presenter.setIdKey(DELETED_ID); + presenter.loadingData(DELETED_ID); + } + + private SchedulerProvider provider() { + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return Schedulers.trampoline(); + } + + @Override + public Scheduler computation() { + return debounceClock; + } + + @Override + public Scheduler io() { + return Schedulers.trampoline(); + } + }; + } + + private void typeAndWait(String value) { + presenter.simpleNoteChange(null, value, false); + debounceClock.advanceTimeBy(AutoSave.AUTO_SAVE_DELAY, TimeUnit.MILLISECONDS); + } + + @Test + public void anEditToADeletedNote_revivesItUnderItsOwnId() { + existingIds.remove(DELETED_ID); + + typeAndWait("typed after the delete"); + + assertThat(written).containsExactly("revived " + DELETED_ID + ":typed after the delete"); + assertThat(presenter.getIdKey()).isEqualTo(DELETED_ID); + assertThat(presenter.getNote().getId()).isEqualTo(DELETED_ID); + assertThat(presenter.hasUnsavedChanges()).isFalse(); + verify(dataManager, never()).addNote(any()); + } + + @Test + public void laterEdits_updateTheRevivedNote() { + existingIds.remove(DELETED_ID); + typeAndWait("first"); + + typeAndWait("second"); + + assertThat(written) + .containsExactly("revived " + DELETED_ID + ":first", DELETED_ID + ":second") + .inOrder(); + verify(dataManager, times(1)).reviveNote(any()); + } + + @Test + public void flushOfADeletedNote_revivesIt() { + existingIds.remove(DELETED_ID); + presenter.simpleNoteChange(null, "typed before leaving", false); + + presenter.flushPending(); + + assertThat(written).containsExactly("revived " + DELETED_ID + ":typed before leaving"); + } + + @Test + public void otherErrors_areReportedAndCreateNothing() { + when(dataManager.updateNote(any())) + .thenReturn(Completable.error(new IllegalStateException("disk full"))); + + typeAndWait("typed"); + + verify(dataManager, never()).reviveNote(any()); + verify(dataManager, never()).addNote(any()); + ArgumentCaptor states = ArgumentCaptor.forClass(SaveState.class); + verify(view, org.mockito.Mockito.atLeastOnce()).updateSaveStatus(states.capture()); + assertThat(states.getAllValues()).contains(SaveState.ERROR); + assertThat(presenter.hasUnsavedChanges()).isTrue(); + } + + @Test + public void anExistingNote_isUpdatedInPlace() { + typeAndWait("typed"); + + assertThat(written).containsExactly(DELETED_ID + ":typed"); + verify(dataManager, never()).reviveNote(any()); + assertThat(presenter.getIdKey()).isEqualTo(DELETED_ID); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java index 5e34ec40..2a9a9025 100644 --- a/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java +++ b/app/src/test/java/com/pasich/mynotes/presenter/NotePresenterFlushTest.java @@ -201,4 +201,64 @@ public void emptiedExtendedNoteNeitherSavesNorCleansAttachments() { verify(dataManager, never()).updateNote(any()); verify(view, never()).runAttachmentsCleanup(any()); } + + @Test + public void theOtherEditorOpensOnlyOnceTheLastEditIsWritten() { + List events = new ArrayList<>(); + presenter.simpleNoteChange(null, "typed just now", false); + + presenter.flushPending(() -> events.add("done after " + writtenValues)); + assertThat(events).isEmpty(); + + ioScheduler.triggerActions(); + assertThat(events).containsExactly("done after [typed just now]"); + } + + @Test + public void withNothingToWriteTheFlushIsDoneAtOnce() { + List events = new ArrayList<>(); + + presenter.flushPending(() -> events.add("done")); + + assertThat(events).containsExactly("done"); + verify(dataManager, never()).updateNote(any()); + } + + @Test + public void aFlushThatReplacesOneInFlightKeepsItsWaiter() { + List events = new ArrayList<>(); + presenter.simpleNoteChange(null, "first", false); + presenter.flushPending(() -> events.add("switch")); + + // The screen stops before the first write ran: onStop flushes again. + presenter.simpleNoteChange(null, "second", false); + presenter.flushPending(); + ioScheduler.triggerActions(); + + assertThat(events).containsExactly("switch"); + assertThat(writtenValues).contains("second"); + } + + /** + * Back on a new note just typed into: once the editor has handed over its document, the note is + * saved and the screen closes; it is not taken for an empty note and deleted. + */ + @Test + public void closingANewNoteAfterTheFlushedDocumentArrivesSavesIt() { + presenter.setNewNoteKey(true); + presenter.setExtendedEditor(true); + note.setValue(""); + note.setTitle(""); + presenter.setNote(note); + when(dataManager.deleteNote(any(Note.class))).thenReturn(Completable.complete()); + + // What the page's flush delivers before the close is decided. + presenter.extendedNoteChange(null, DOC_HELLO); + presenter.flushPending(); + presenter.closeActivity(); + ioScheduler.triggerActions(); + + verify(dataManager, never()).deleteNote(any(Note.class)); + verify(view).closeNoteActivity(); + } } diff --git a/app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java b/app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java new file mode 100644 index 00000000..2b7bc656 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/TagDialogPresentersTest.java @@ -0,0 +1,150 @@ +package com.pasich.mynotes.presenter; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.contract.dialogs.DeleteTagDialogContract; +import com.pasich.mynotes.ui.contract.dialogs.NameTagDialogContract; +import com.pasich.mynotes.ui.presenter.dialogs.DeleteTagDialogPresenter; +import com.pasich.mynotes.ui.presenter.dialogs.NameTagDialogPresenter; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Scheduler; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.TestScheduler; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +public class TagDialogPresentersTest extends BasePresenterTest { + + @Mock DataManager dataManager; + @Mock DeleteTagDialogContract.view deleteView; + @Mock NameTagDialogContract.view nameView; + + private final TestScheduler io = new TestScheduler(); + + @Before + public void setUp() { + initMocks(this); + } + + /** io work waits for {@link #io} to be triggered, like a busy io pool. */ + private SchedulerProvider queuedIo() { + SchedulerProvider immediate = testSchedulerProvider(); + return new SchedulerProvider() { + @Override + public Scheduler ui() { + return immediate.ui(); + } + + @Override + public Scheduler computation() { + return immediate.computation(); + } + + @Override + public Scheduler io() { + return io; + } + }; + } + + private static Completable flagging(AtomicBoolean ran) { + return Completable.fromAction(() -> ran.set(true)); + } + + @Test + public void deleteTag_dialogClosedBeforeTheWriteStarts_stillDeletes() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.clearTagInNotes(tag)).thenReturn(flagging(ran)); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.deleteTagUnchecked(tag); + presenter.detachView(); // the dialog dismisses in the same click + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } + + @Test + public void deleteTagAndNotes_dialogClosedBeforeTheWriteStarts_stillDeletes() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.deleteTagAndMoveNotesToTrash(tag)).thenReturn(flagging(ran)); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.deleteTagAndNotes(tag); + presenter.detachView(); + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } + + @Test + public void notesCount_arrivesLater_isShown() { + when(dataManager.getCountNotesTag("Work")).thenReturn(Single.just(3)); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter( + testSchedulerProvider(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.getLoadCountNotesForTag("Work"); + + verify(deleteView).showNotesCount(3); + } + + @Test + public void notesCount_failing_doesNotCrash() { + when(dataManager.getCountNotesTag("Work")) + .thenReturn(Single.error(new IllegalStateException("db closed"))); + DeleteTagDialogPresenter presenter = + new DeleteTagDialogPresenter( + testSchedulerProvider(), new CompositeDisposable(), dataManager); + presenter.attachView(deleteView); + + presenter.getLoadCountNotesForTag("Work"); + } + + @Test + public void saveTag_dialogClosedBeforeTheWriteStarts_stillSaves() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.addTag(tag)).thenReturn(flagging(ran)); + NameTagDialogPresenter presenter = + new NameTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(nameView); + + presenter.saveTag(tag); + presenter.detachView(); + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } + + @Test + public void renameTag_dialogClosedBeforeTheWriteStarts_stillRenames() { + AtomicBoolean ran = new AtomicBoolean(); + Tag tag = new Tag().create("Work"); + when(dataManager.renameTag(tag, "Home")).thenReturn(flagging(ran)); + NameTagDialogPresenter presenter = + new NameTagDialogPresenter(queuedIo(), new CompositeDisposable(), dataManager); + presenter.attachView(nameView); + + presenter.editNameTag("Home", tag); + presenter.detachView(); + io.triggerActions(); + + org.junit.Assert.assertTrue(ran.get()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java b/app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java new file mode 100644 index 00000000..e6fb534e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/presenter/TasksPresenterTest.java @@ -0,0 +1,118 @@ +package com.pasich.mynotes.presenter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import com.pasich.mynotes.base.BasePresenterTest; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.ui.contract.TasksContract; +import com.pasich.mynotes.ui.presenter.TasksPresenter; +import com.pasich.mynotes.utils.rx.SchedulerProvider; +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Scheduler; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.TestScheduler; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +public class TasksPresenterTest extends BasePresenterTest { + + @Mock DataManager dataManager; + @Mock TasksContract.view view; + + private final TestScheduler io = new TestScheduler(); + private TasksPresenter presenter; + + @Before + public void setUp() { + initMocks(this); + when(dataManager.getCategories()).thenReturn(Flowable.just(Collections.emptyList())); + when(dataManager.getActiveTasks()).thenReturn(Flowable.just(Collections.emptyList())); + when(dataManager.getActiveTasksByCategory(anyInt())) + .thenReturn(Flowable.just(Collections.emptyList())); + when(dataManager.getCompletedTasks()).thenReturn(Flowable.just(Collections.emptyList())); + SchedulerProvider immediate = testSchedulerProvider(); + SchedulerProvider queued = + new SchedulerProvider() { + @Override + public Scheduler ui() { + return immediate.ui(); + } + + @Override + public Scheduler computation() { + return immediate.computation(); + } + + @Override + public Scheduler io() { + return io; + } + }; + presenter = + new TasksPresenter( + queued, new CompositeDisposable(), dataManager, mock(Context.class)); + presenter.attachView(view); + presenter.viewIsReady(); + io.triggerActions(); + } + + private static Task task(int id, int position) { + Task task = new Task("T" + id, 0); + task.setId(id); + task.setPosition(position); + return task; + } + + @Test + @SuppressWarnings("unchecked") + public void updatePositions_writesOnlyTheMovedTasks_inOneCall() { + when(dataManager.updateTasks(anyList())).thenReturn(Completable.complete()); + // Task 3 dragged to the top: all three moved. + List order = new ArrayList<>(Arrays.asList(task(3, 2), task(1, 0), task(2, 1))); + + presenter.updatePositions(order); + io.triggerActions(); + + ArgumentCaptor> written = ArgumentCaptor.forClass(List.class); + verify(dataManager).updateTasks(written.capture()); + assertEquals(3, written.getValue().size()); + verify(dataManager, never()).updateTask(org.mockito.ArgumentMatchers.any()); + } + + @Test + public void updatePositions_nothingMoved_writesNothing() { + presenter.updatePositions(Arrays.asList(task(1, 0), task(2, 1))); + + verify(dataManager, never()).updateTasks(anyList()); + } + + @Test + public void switchingCategory_doesNotCancelAWriteInFlight() { + AtomicBoolean ran = new AtomicBoolean(); + when(dataManager.updateTask(org.mockito.ArgumentMatchers.any())) + .thenReturn(Completable.fromAction(() -> ran.set(true))); + + presenter.editTask(task(1, 0), "New title", null); + presenter.onCategorySelected(5); // used to clear every subscription + io.triggerActions(); + + assertTrue(ran.get()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java b/app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java new file mode 100644 index 00000000..2d138563 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/receiver/TaskReminderReceiverTest.java @@ -0,0 +1,142 @@ +package com.pasich.mynotes.ui.receiver; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.Manifest; +import android.app.AlarmManager; +import android.app.Application; +import android.app.NotificationManager; +import android.content.Context; +import androidx.room.Room; +import com.pasich.mynotes.cache.NotificationPreferencesCache; +import com.pasich.mynotes.data.DataManager; +import com.pasich.mynotes.data.database.AppDatabase; +import com.pasich.mynotes.data.model.Task; +import io.reactivex.Completable; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlarmManager; + +/** A task reminder is checked against the stored task before it is announced. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class TaskReminderReceiverTest { + + private static final long NOW = 1_780_000_000_000L; + + private Application context; + private AppDatabase db; + private DataManager dataManager; + private TaskReminderReceiver receiver; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + shadowOf(context).grantPermissions(Manifest.permission.POST_NOTIFICATIONS); + ShadowAlarmManager.setCanScheduleExactAlarms(true); + db = + Room.inMemoryDatabaseBuilder(context, AppDatabase.class) + .allowMainThreadQueries() + .build(); + dataManager = mock(DataManager.class); + when(dataManager.clearTaskReminder(anyInt())).thenReturn(Completable.complete()); + when(dataManager.setTaskReminderFull(anyInt(), anyLong(), anyInt())) + .thenReturn(Completable.complete()); + NotificationPreferencesCache channels = mock(NotificationPreferencesCache.class); + when(channels.getChannelId()).thenReturn("reminders"); + + receiver = new TaskReminderReceiver(); + receiver.dataManager = dataManager; + receiver.database = db; + receiver.notificationPreferencesCache = channels; + } + + @After + public void tearDown() { + db.close(); + ShadowAlarmManager.reset(); + } + + private int task(long time, int intervalMinutes, boolean done) { + Task task = new Task("Buy milk", 0); + task.setReminderTime(time); + task.setReminderIntervalMinutes(intervalMinutes); + task.setDone(done); + return (int) db.taskDao().insertTask(task); + } + + private int posted() { + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + return shadowOf(nm).getAllNotifications().size(); + } + + private int armed() { + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + return shadowOf(am).getScheduledAlarms().size(); + } + + @Test + public void deletedTask_isNotAnnouncedNorRearmed() { + receiver.handle(context, 404, NOW); + + assertThat(posted()).isEqualTo(0); + assertThat(armed()).isEqualTo(0); + verify(dataManager, never()).setTaskReminderFull(anyInt(), anyLong(), anyInt()); + } + + @Test + public void completedTask_isNotAnnounced() { + int id = task(NOW, 15, true); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(0); + assertThat(armed()).isEqualTo(0); + } + + @Test + public void oneTimeReminder_isAnnouncedAndCleared() { + int id = task(NOW, 0, false); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(1); + verify(dataManager).clearTaskReminder(id); + assertThat(armed()).isEqualTo(0); + } + + @Test + public void repeatingReminder_isAnnouncedAndArmedAgain() { + int id = task(NOW, 15, false); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(1); + verify(dataManager).setTaskReminderFull(eq(id), eq(NOW + 15 * 60_000L), eq(15)); + assertThat(armed()).isEqualTo(1); + } + + @Test + public void reminderMovedLater_waitsForItsNewTime() { + int id = task(NOW + 3_600_000L, 0, false); + + receiver.handle(context, id, NOW); + + assertThat(posted()).isEqualTo(0); + assertThat(armed()).isEqualTo(1); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java index c289aa99..79ef9f34 100644 --- a/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java +++ b/app/src/test/java/com/pasich/mynotes/ui/sync/SyncCoordinatorTest.java @@ -303,6 +303,159 @@ public void syncNow_doesNotRequireAFeatureRollout() { assertThat(callback.error).isNull(); } + @Test + public void syncNow_queuedBehindADisconnectDoesNotTurnSyncBackOn() { + // The worker used to switch sync back on right before running it, so a disconnect tapped + // while the sync waited in the queue was undone and the signed-out account synced anyway. + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onAuthorized("access-token"); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + store.syncEnabledProbe = preferences; + List queued = new ArrayList<>(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + queued::add, + directExecutor); + + coordinator.syncNow(mock(Activity.class), new CapturingCallback<>()); + assertThat(preferences.syncEnabled).isTrue(); + coordinator.disconnect(new CapturingCallback<>()); + for (Runnable task : new ArrayList<>(queued)) { + task.run(); + } + + assertThat(store.syncEnabledAtSync).isFalse(); + assertThat(preferences.syncEnabled).isFalse(); + } + + @Test + public void syncNow_refusedByAGoneScreenLeavesSyncDisabledAndReportsIt() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onAuthorized("access-token"); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + task -> { + throw new java.util.concurrent.RejectedExecutionException("shut down"); + }, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + + assertThat(preferences.syncEnabled).isFalse(); + assertThat(store.lastToken).isNull(); + assertThat(callback.error) + .isInstanceOf(java.util.concurrent.RejectedExecutionException.class); + } + + @Test + public void syncNow_declinedAuthorizationLeavesSyncDisabled() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + Mockito.doAnswer( + invocation -> { + GoogleDriveAuthorization.Callback callback = invocation.getArgument(1); + callback.onError(new IllegalStateException("cancelled")); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + directExecutor, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + + assertThat(callback.error).isNotNull(); + assertThat(preferences.syncEnabled).isFalse(); + assertThat(store.lastToken).isNull(); + } + + @Test + public void syncNow_disconnectWhileAuthorizingDoesNotTurnSyncBackOn() { + FakePreferenceHelper preferences = new FakePreferenceHelper(); + preferences.firstSyncConfirmed = true; + GoogleDriveAuthorization authorization = mock(GoogleDriveAuthorization.class); + List pending = new ArrayList<>(); + Mockito.doAnswer( + invocation -> { + pending.add(invocation.getArgument(1)); + return null; + }) + .when(authorization) + .authorize( + Mockito.any(Activity.class), + Mockito.any(GoogleDriveAuthorization.Callback.class)); + FakeConflictStore store = new FakeConflictStore(); + SyncCoordinator coordinator = + new SyncCoordinator( + preferences, + firebaseAuth(mock(FirebaseUser.class)), + mock(GoogleCredentialAuth.class), + authorization, + store, + new FakeScheduler(), + directExecutor, + directExecutor); + + CapturingCallback callback = new CapturingCallback<>(); + coordinator.syncNow(mock(Activity.class), callback); + assertThat(preferences.syncEnabled).isFalse(); + coordinator.disconnect(new CapturingCallback<>()); + pending.get(0).onAuthorized("access-token"); + + assertThat(preferences.syncEnabled).isFalse(); + assertThat(store.lastToken).isNull(); + assertThat(callback.error).isNotNull(); + } + @Test public void resolveConflict_updatesStoreAndReturnsLatestConflicts() { FakePreferenceHelper preferences = new FakePreferenceHelper(); @@ -385,6 +538,8 @@ private static final class FakeConflictStore implements SyncCoordinator.Conflict private final List resolutions = new ArrayList<>(); private String lastToken; private int clearCalls; + private FakePreferenceHelper syncEnabledProbe; + private Boolean syncEnabledAtSync; @NonNull @Override @@ -410,6 +565,9 @@ public void resolveConflict(long conflictId, @NonNull SyncResolution resolution) @Override public SyncState sync(@NonNull String accessToken) { lastToken = accessToken; + if (syncEnabledProbe != null) { + syncEnabledAtSync = syncEnabledProbe.syncEnabled; + } return state; } diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java new file mode 100644 index 00000000..da92621e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorDraftStateTest.java @@ -0,0 +1,32 @@ +package com.pasich.mynotes.ui.view.activity.noteEditor; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * The simple editor has no length limit, so an unsaved draft is put in the saved state only while + * it is small: a long note there crashed the app with TransactionTooLargeException. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SimpleEditorDraftStateTest { + + @Test + public void aShortDraftGoesInTheSavedState() { + assertThat(NoteActivity.draftFitsSavedState("title", "x".repeat(1000))).isTrue(); + assertThat(NoteActivity.draftFitsSavedState(null, null)).isTrue(); + } + + @Test + public void aLongDraftIsLeftOut() { + assertThat(NoteActivity.draftFitsSavedState("", "x".repeat(NoteActivity.MAX_DRAFT_CHARS))) + .isTrue(); + assertThat(NoteActivity.draftFitsSavedState("t", "x".repeat(NoteActivity.MAX_DRAFT_CHARS))) + .isFalse(); + assertThat(NoteActivity.draftFitsSavedState("", "x".repeat(500_000))).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorEmojiCompatTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorEmojiCompatTest.java new file mode 100644 index 00000000..5ceca01f --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorEmojiCompatTest.java @@ -0,0 +1,41 @@ +package com.pasich.mynotes.ui.view.activity.noteEditor; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.AppCompatEditText; +import com.pasich.mynotes.R; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SimpleEditorEmojiCompatTest { + + /** Inflates the note screen the way the app does, through AppCompat. */ + public static class Host extends AppCompatActivity { + @Override + protected void onCreate(Bundle state) { + setTheme(R.style.DefaultTheme); + super.onCreate(state); + setContentView(R.layout.activity_note); + } + } + + /** + * EmojiCompat on the note body ends every edit with a change report for the whole text, so the + * field laid out the whole note again on each keystroke: typing in a 109 KB note took more than + * a full core on a Pixel 7a. The system emoji font draws emoji without it. + */ + @Test + public void theNoteBodyDoesNotRunEmojiCompat() { + Host host = Robolectric.buildActivity(Host.class).setup().get(); + AppCompatEditText body = host.findViewById(R.id.valueNote); + + assertThat(body.isEmojiCompatEnabled()).isFalse(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java new file mode 100644 index 00000000..223ae8fe --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/activity/noteEditor/SimpleEditorLengthTest.java @@ -0,0 +1,39 @@ +package com.pasich.mynotes.ui.view.activity.noteEditor; + +import static com.google.common.truth.Truth.assertThat; + +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; +import com.pasich.mynotes.R; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class SimpleEditorLengthTest { + + /** + * A note longer than any input limit (shared into the app, written in the extended editor, or + * restored from a backup) must reach the simple editor whole. A length filter also cuts the + * text passed to setText, and the next autosave then wrote the cut text over the note. + */ + @Test + public void aLongNoteIsShownWhole() { + View root = + LayoutInflater.from( + new ContextThemeWrapper( + RuntimeEnvironment.getApplication(), R.style.DefaultTheme)) + .inflate(R.layout.activity_note, null); + EditText body = root.findViewById(R.id.valueNote); + String note = "x".repeat(109_000); + + body.setText(note); + + assertThat(body.getText().length()).isEqualTo(note.length()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java new file mode 100644 index 00000000..a6095229 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/BackupOptionsDialogTest.java @@ -0,0 +1,35 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class BackupOptionsDialogTest { + + @Test + public void canBeRecreatedByTheFramework() { + // What the fragment manager does after a rotation or process death. + Fragment recreated = + new FragmentFactory() + .instantiate( + getClass().getClassLoader(), BackupOptionsDialog.class.getName()); + + assertTrue(recreated instanceof BackupOptionsDialog); + } + + @Test + public void keepsTheModeInItsArguments() { + BackupOptionsDialog dialog = BackupOptionsDialog.newInstance(true); + + assertNotNull(dialog.getArguments()); + assertTrue(dialog.getArguments().getBoolean("isRestore")); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java new file mode 100644 index 00000000..46c8393e --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/ShareOptionsDialogArgsTest.java @@ -0,0 +1,74 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; +import com.pasich.mynotes.data.model.Note; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** The share dialog keeps what it needs in its arguments and never exports nothing silently. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ShareOptionsDialogArgsTest { + + private static Note note(int id, String title, String value) { + Note note = new Note().create(title, value, 1L, ""); + note.setId(id); + return note; + } + + @Test + public void recreatedDialog_keepsTheNoteIdsAndFlags() { + ShareOptionsDialog original = + ShareOptionsDialog.newInstance( + Arrays.asList(note(3, "A", "a"), note(9, "B", "b")), true); + + Fragment rebuilt = + new FragmentFactory() + .instantiate( + getClass().getClassLoader(), ShareOptionsDialog.class.getName()); + rebuilt.setArguments(new Bundle(original.requireArguments())); + + Bundle args = rebuilt.requireArguments(); + assertThat(args.getIntArray("noteIds")).asList().containsExactly(3, 9).inOrder(); + assertThat(ShareOptionsDialog.exportTitle(args)).isEqualTo("Data_Export"); + } + + @Test + public void singleNote_titleComesFromTheArguments() { + Bundle args = ShareOptionsDialog.arguments(List.of(note(1, "", "text")), true, false); + + assertThat(ShareOptionsDialog.exportTitle(args)).isEqualTo("***"); + ShareOptionsDialog.Export export = + ShareOptionsDialog.buildExport(args, List.of(note(1, "", "text"))); + assertThat(export.title).isEqualTo("***"); + assertThat(export.content).isEqualTo("text"); + } + + @Test + public void severalNotes_areJoinedInOrder() { + List notes = Arrays.asList(note(1, "One", "1"), note(2, "Two", "2")); + Bundle args = ShareOptionsDialog.arguments(notes, false, false); + + ShareOptionsDialog.Export export = ShareOptionsDialog.buildExport(args, notes); + + assertThat(export.title).isEqualTo("Multiple_Notes"); + assertThat(export.content).isEqualTo("One\n\n1\n\n\n===================\n\nTwo\n\n2"); + } + + @Test + public void notesGoneMeanwhile_nothingToExport() { + Bundle args = ShareOptionsDialog.arguments(List.of(note(1, "A", "a")), false, true); + + assertThat(ShareOptionsDialog.buildExport(args, Collections.emptyList())).isNull(); + assertThat(ShareOptionsDialog.buildExport(args, null)).isNull(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java new file mode 100644 index 00000000..4d78ac17 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/ui/view/dialogs/TagDialogsRecreationTest.java @@ -0,0 +1,86 @@ +package com.pasich.mynotes.ui.view.dialogs; + +import static com.google.common.truth.Truth.assertThat; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.ui.view.dialogs.main.DeleteTagDialog; +import com.pasich.mynotes.ui.view.dialogs.main.NameTagDialog; +import com.pasich.mynotes.ui.view.dialogs.main.TagArgs; +import com.pasich.mynotes.ui.view.dialogs.main.TagOptionsBottomSheet; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * After a rotation or process death the system rebuilds a dialog through its no-argument + * constructor and hands it the saved arguments; everything a dialog needs must be in them. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class TagDialogsRecreationTest { + + private static Tag tag() { + Tag tag = new Tag().create("Work"); + tag.id = 42; + tag.setVisibility(1); + tag.setPosition(3); + return tag; + } + + /** Rebuilds {@code original} the way the FragmentManager does. */ + private static Fragment recreate(Fragment original) { + Fragment fragment = + new FragmentFactory() + .instantiate( + TagDialogsRecreationTest.class.getClassLoader(), + original.getClass().getName()); + Bundle args = original.getArguments(); + fragment.setArguments(args == null ? null : new Bundle(args)); + return fragment; + } + + private static void assertSameTag(Tag actual) { + assertThat(actual).isNotNull(); + assertThat(actual.getId()).isEqualTo(42); + assertThat(actual.getNameTag()).isEqualTo("Work"); + assertThat(actual.getVisibility()).isEqualTo(1); + assertThat(actual.getPosition()).isEqualTo(3); + } + + @Test + public void deleteTagDialog_isRebuiltWithItsTag() { + Fragment fragment = recreate(DeleteTagDialog.newInstance(tag())); + + assertThat(fragment).isInstanceOf(DeleteTagDialog.class); + assertSameTag(TagArgs.get(fragment.getArguments())); + } + + @Test + public void renameTagDialog_isRebuiltWithItsTag() { + Fragment fragment = recreate(NameTagDialog.newRenameInstance(tag())); + + assertThat(fragment).isInstanceOf(NameTagDialog.class); + assertSameTag(TagArgs.get(fragment.getArguments())); + } + + @Test + public void createTagDialog_isRebuiltWithoutATag() { + Fragment fragment = recreate(NameTagDialog.newCreateInstance(5)); + + assertThat(fragment).isInstanceOf(NameTagDialog.class); + assertThat(TagArgs.get(fragment.getArguments())).isNull(); + } + + @Test + public void tagOptionsSheet_isRebuiltWithItsTag() { + Fragment fragment = recreate(TagOptionsBottomSheet.newInstance(tag(), 7)); + + assertThat(fragment).isInstanceOf(TagOptionsBottomSheet.class); + assertSameTag(TagArgs.get(fragment.getArguments())); + assertThat(fragment.requireArguments().getInt("notesCount")).isEqualTo(7); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java b/app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java new file mode 100644 index 00000000..f8ebdea3 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/adapters/TasksAdapterDiffTest.java @@ -0,0 +1,96 @@ +package com.pasich.mynotes.utils.adapters; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.recyclerview.widget.RecyclerView; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.utils.adapters.tasks.TasksAdapter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** A database change rebinds only the rows it touched. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class TasksAdapterDiffTest { + + private TasksAdapter adapter; + private final List events = new ArrayList<>(); + + @Before + public void setUp() { + adapter = new TasksAdapter(t -> {}, t -> {}, t -> {}, () -> {}, h -> {}, t -> {}); + adapter.registerAdapterDataObserver( + new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + events.add("all"); + } + + @Override + public void onItemRangeChanged(int start, int count, Object payload) { + events.add("changed " + start + "+" + count); + } + + @Override + public void onItemRangeInserted(int start, int count) { + events.add("inserted " + start + "+" + count); + } + + @Override + public void onItemRangeRemoved(int start, int count) { + events.add("removed " + start + "+" + count); + } + + @Override + public void onItemRangeMoved(int from, int to, int count) { + events.add("moved " + from + ">" + to); + } + }); + } + + private static Task task(int id, String title) { + Task task = new Task(title, 0); + task.setId(id); + task.setCreatedAt(1L); + return task; + } + + @Test + public void sameTasksAgain_nothingIsRebound() { + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "b")), Collections.emptyList()); + events.clear(); + + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "b")), Collections.emptyList()); + + assertThat(events).isEmpty(); + } + + @Test + public void oneTaskRenamed_onlyItsRowChanges() { + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "b")), Collections.emptyList()); + events.clear(); + + adapter.submitList(Arrays.asList(task(1, "a"), task(2, "B")), Collections.emptyList()); + + assertThat(events).containsExactly("changed 1+1"); + } + + @Test + public void taskEditedInPlaceThenReloaded_rowIsRebound() { + Task bound = task(1, "a"); + adapter.submitList(Collections.singletonList(bound), Collections.emptyList()); + events.clear(); + + bound.setTitle("edited"); // what TasksPresenter.editTask does before writing + adapter.submitList(Collections.singletonList(task(1, "edited")), Collections.emptyList()); + + assertThat(events).containsExactly("changed 0+1"); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java b/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java index 24c7e3e7..ebc46640 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/backup/local/ZipBackupHelperTest.java @@ -105,6 +105,51 @@ public void refusesAnArchiveThatUnpacksPastTheCeiling() throws Exception { assertThat(backup.isError()).isTrue(); } + @Test + public void writtenArchive_readsBackWithItsAttachments() throws Exception { + File attachments = temporaryFolder.newFolder("files", "attachments"); + File noteDir = new File(attachments, "note_3"); + assertThat(noteDir.mkdirs()).isTrue(); + // Larger than one copy chunk, so the streamed copy is exercised past its first read. + byte[] photo = new byte[200 * 1024 + 17]; + for (int i = 0; i < photo.length; i++) photo[i] = (byte) i; + Files.write(new File(noteDir, "photo.jpg").toPath(), photo); + File zipFile = new File(temporaryFolder.getRoot(), "backup.mnbkn"); + File staging = new File(temporaryFolder.getRoot(), "staging/attachments"); + + ZipBackupHelper.writeZipBackup(new JsonBackup(), attachments, zipFile); + JsonBackup backup; + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zipFile.toPath()))) { + backup = ZipBackupHelper.readZipBackup(zis, staging); + } + + assertThat(backup.isError()).isFalse(); + assertThat(Files.readAllBytes(new File(new File(staging, "note_3"), "photo.jpg").toPath())) + .isEqualTo(photo); + } + + @Test + public void writesAnArchiveWithNoAttachmentFolder() throws Exception { + File zipFile = new File(temporaryFolder.getRoot(), "backup.mnbkn"); + + ZipBackupHelper.writeZipBackup( + new JsonBackup(), new File(temporaryFolder.getRoot(), "missing"), zipFile); + + try (java.io.InputStream in = Files.newInputStream(zipFile.toPath())) { + assertThat(ZipBackupHelper.isZip(in)).isTrue(); + } + } + + @Test + public void isZip_readsOnlyTheMagicFromAStream() throws Exception { + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream(new byte[] {0x50, 0x4B, 3}))) + .isTrue(); + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream("{\"a\":1}".getBytes()))) + .isFalse(); + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream(new byte[] {0x50}))).isFalse(); + assertThat(ZipBackupHelper.isZip(new ByteArrayInputStream(new byte[0]))).isFalse(); + } + private static ZipInputStream zip(Map entries) throws Exception { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(bytes)) { diff --git a/app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java b/app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java new file mode 100644 index 00000000..e6679a54 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/backup/models/googleKeep/GoogleKeepNoteTest.java @@ -0,0 +1,47 @@ +package com.pasich.mynotes.utils.backup.models.googleKeep; + +import static org.junit.Assert.assertEquals; + +import com.google.gson.Gson; +import org.junit.Test; + +public class GoogleKeepNoteTest { + + private static GoogleKeepNote parse(String json) { + return new Gson().fromJson(json, GoogleKeepNote.class); + } + + @Test + public void textNote_keepsItsText() { + GoogleKeepNote note = parse("{\"title\":\"T\",\"textContent\":\"hello\"}"); + + assertEquals("hello", note.toAppNotes().getValue()); + } + + @Test + public void checklistNote_importsItsItemsWithTheirState() { + GoogleKeepNote note = + parse( + "{\"title\":\"Shopping\",\"listContent\":[" + + "{\"text\":\"Milk\",\"isChecked\":false}," + + "{\"text\":\"Bread\",\"isChecked\":true}]}"); + + assertEquals("[ ] Milk\n[x] Bread", note.toAppNotes().getValue()); + assertEquals("[ ] Milk\n[x] Bread", note.toAppNotesTrash().getValue()); + } + + @Test + public void checklistWithText_keepsBoth() { + GoogleKeepNote note = + parse( + "{\"textContent\":\"intro\",\"listContent\":[" + + "{\"text\":\"a\",\"isChecked\":false}]}"); + + assertEquals("intro\n[ ] a", note.getBodyText()); + } + + @Test + public void noContentAtAll_isEmptyNotNull() { + assertEquals("", parse("{\"title\":\"x\"}").getBodyText()); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java index cf95abe0..b315f222 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/EditableLinkMovementMethodTest.java @@ -67,5 +67,10 @@ public void upInSelectingModeWithoutARecordedDown_doesNotCrashAndKeepsTheCaret() assertThat(handled).isTrue(); assertThat(Selection.getSelectionStart(buffer)).isEqualTo(caretBefore); assertThat(Selection.getSelectionEnd(buffer)).isEqualTo(caretBefore); + // The platform ends selecting mode after an up; skipping it would leave Shift stuck. + assertThat( + android.text.method.MetaKeyKeyListener.getMetaState( + buffer, KeyEvent.META_SHIFT_ON)) + .isEqualTo(0); } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java new file mode 100644 index 00000000..9cf65be1 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/editor/RetainedNoteDraftTest.java @@ -0,0 +1,127 @@ +package com.pasich.mynotes.utils.editor; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class RetainedNoteDraftTest { + + @Test + public void theDraftIsTakenOnceForItsNote() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "title", "body"); + + RetainedNoteDraft.Draft draft = retained.take(7); + assertThat(draft.title).isEqualTo("title"); + assertThat(draft.body).isEqualTo("body"); + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aLaterDocumentReplacesTheBodyAndKeepsTheTitle() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "title", "[1]"); + retained.put(7, null, "[2]"); + + RetainedNoteDraft.Draft draft = retained.take(7); + assertThat(draft.title).isEqualTo("title"); + assertThat(draft.body).isEqualTo("[2]"); + } + + @Test + public void anotherNote_getsNothing() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, null, "[1]"); + + assertThat(retained.take(8)).isNull(); + assertThat(retained.take(7)).isNull(); + } + + /** + * After a rotation the new screen shows the note only once the old page has handed over its + * last document, which arrives after the new screen has started. + */ + @Test + public void theNewScreenWaitsForTheOldPage() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + List shown = new ArrayList<>(); + retained.previousPageFinishing(); + + retained.whenPreviousPageFinished(() -> shown.add(retained.take(7).body)); + assertThat(shown).isEmpty(); + + retained.put(7, null, "[last]"); + retained.previousPageFinished(); + assertThat(shown).containsExactly("[last]"); + } + + @Test + public void withNoPageFinishingTheNoteIsShownAtOnce() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + List shown = new ArrayList<>(); + + retained.whenPreviousPageFinished(() -> shown.add("now")); + + assertThat(shown).containsExactly("now"); + } + + @Test + public void twoQuickRecreationsWaitForBothPages() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + List shown = new ArrayList<>(); + retained.previousPageFinishing(); + retained.previousPageFinishing(); + retained.whenPreviousPageFinished(() -> shown.add("now")); + + retained.previousPageFinished(); + assertThat(shown).isEmpty(); + retained.previousPageFinished(); + assertThat(shown).containsExactly("now"); + } + + /** + * Going to the background saves the screen's state without recreating it: the screen comes back + * as itself and keeps editing, so nothing may be kept for a later recreation to apply. + */ + @Test + public void backgroundThenReturnThenEditThenRotate_doesNotRestoreTheStaleDraft() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + // Home with an unsaved long draft (v1): not a recreation. + retained.keepForRecreation(false, 7, "t1", "v1"); + // Back on the screen, v3 is typed and saved; the rotation has no unsaved draft to keep. + // The next screen finds nothing and shows the saved v3. + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aBackgroundSave_forgetsADraftLeftFromBefore() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "t1", "v1"); + + retained.keepForRecreation(false, 7, "t3", "v3"); + + assertThat(retained.take(7)).isNull(); + } + + @Test + public void aRotationKeepsTheDraftForTheNextScreen() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.keepForRecreation(true, 7, "t3", "v3"); + + RetainedNoteDraft.Draft draft = retained.take(7); + assertThat(draft.title).isEqualTo("t3"); + assertThat(draft.body).isEqualTo("v3"); + } + + @Test + public void discard_forgetsTheDraft() { + RetainedNoteDraft retained = new RetainedNoteDraft(); + retained.put(7, "t1", "v1"); + + retained.discard(); + + assertThat(retained.take(7)).isNull(); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java new file mode 100644 index 00000000..a956dbfc --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/EnclyExportedSnapshotTest.java @@ -0,0 +1,175 @@ +package com.pasich.mynotes.utils.encly; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import com.pasich.mynotes.data.model.Note; +import com.pasich.mynotes.data.model.Tag; +import com.pasich.mynotes.data.model.Task; +import com.pasich.mynotes.data.model.TaskCategory; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; + +/** + * The clear after a hand-off deletes what the hand-off carried, and nothing added or changed since. + */ +public class EnclyExportedSnapshotTest { + + private static Note note(int id) { + return note(id, "text " + id); + } + + private static Note note(int id, String value) { + Note note = new Note().create("title", value, 0L, ""); + note.setId(id); + note.setValueJson(""); + note.setAttachments("[]"); + return note; + } + + private static EnclyMigrationRepository.Library library(List notes) { + return new EnclyMigrationRepository.Library( + notes, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + } + + private static List toDelete(List now, EnclyMigrationRepository.Exported exported) { + return EnclyMigrationRepository.onlyExportedUnchanged( + now, + exported.notes, + exported.noteFingerprints, + Note::getId, + EnclyMigrationRepository::fingerprint); + } + + @Test + public void keepsOnlyTheRecordsTheHandoffCarried() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of( + library(Arrays.asList(note(1), note(3), note(4)))); + List now = Arrays.asList(note(1), note(2), note(3), note(9)); + + List deleted = toDelete(now, exported); + + assertEquals(2, deleted.size()); + assertEquals(1, deleted.get(0).getId()); + assertEquals(3, deleted.get(1).getId()); + } + + @Test + public void aNoteChangedSinceTheHandoff_isKept() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of( + library(Arrays.asList(note(1, "carried"), note(2, "carried")))); + // Note 2 was edited, here or by a sync, after the archive was written. + List now = Arrays.asList(note(1, "carried"), note(2, "edited later")); + + List deleted = toDelete(now, exported); + + assertEquals(1, deleted.size()); + assertEquals(1, deleted.get(0).getId()); + } + + @Test + public void aNoteRetitledOrRetaggedSinceTheHandoff_isKept() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of(library(Collections.singletonList(note(1)))); + Note retitled = note(1); + retitled.setTitle("new title"); + Note retagged = note(1); + retagged.setTag("work"); + + assertTrue(toDelete(Collections.singletonList(retitled), exported).isEmpty()); + assertTrue(toDelete(Collections.singletonList(retagged), exported).isEmpty()); + } + + @Test + public void onlyANewEditDate_doesNotKeepTheNote() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of(library(Collections.singletonList(note(1)))); + Note touched = note(1); + touched.setDate(12345L); + touched.setCustomPosition(99L); + + assertEquals(1, toDelete(Collections.singletonList(touched), exported).size()); + } + + @Test + public void changedTasksTagsAndCategories_changeTheirFingerprints() { + Task task = new Task(); + task.setTitle("buy milk"); + long taskPrint = EnclyMigrationRepository.fingerprint(task); + task.setPosition(5); + assertEquals(taskPrint, EnclyMigrationRepository.fingerprint(task)); + task.setDone(true); + assertNotEquals(taskPrint, EnclyMigrationRepository.fingerprint(task)); + + Tag tag = new Tag(); + tag.setNameTag("home"); + long tagPrint = EnclyMigrationRepository.fingerprint(tag); + tag.setNameTag("house"); + assertNotEquals(tagPrint, EnclyMigrationRepository.fingerprint(tag)); + + TaskCategory category = new TaskCategory("errands", "#000000"); + long categoryPrint = EnclyMigrationRepository.fingerprint(category); + category.setName("chores"); + assertNotEquals(categoryPrint, EnclyMigrationRepository.fingerprint(category)); + } + + @Test + public void partsCannotBlurIntoEachOther() { + Note a = note(1, "bc"); + a.setTitle("a"); + Note b = note(1, "c"); + b.setTitle("ab"); + + assertNotEquals( + EnclyMigrationRepository.fingerprint(a), EnclyMigrationRepository.fingerprint(b)); + } + + @Test + public void nothingExported_deletesNothing() { + EnclyMigrationRepository.Exported none = + EnclyMigrationRepository.Exported.of(library(new ArrayList<>())); + + assertTrue(toDelete(Arrays.asList(note(1), note(2)), none).isEmpty()); + assertTrue( + EnclyMigrationRepository.onlyExportedUnchanged( + null, + new long[] {1}, + new long[] {0}, + Note::getId, + EnclyMigrationRepository::fingerprint) + .isEmpty()); + } + + @Test + public void fingerprintsThatDoNotMatchTheIds_deleteNothing() { + assertTrue( + EnclyMigrationRepository.onlyExportedUnchanged( + Collections.singletonList(note(1)), + new long[] {1}, + new long[0], + Note::getId, + EnclyMigrationRepository::fingerprint) + .isEmpty()); + } + + @Test + public void snapshot_recordsEveryIdOfTheLibraryWithItsFingerprint() { + EnclyMigrationRepository.Exported exported = + EnclyMigrationRepository.Exported.of(library(Arrays.asList(note(5), note(6)))); + + assertArrayEquals(new long[] {5, 6}, exported.notes); + assertEquals(2, exported.noteFingerprints.length); + assertEquals(EnclyMigrationRepository.fingerprint(note(5)), exported.noteFingerprints[0]); + assertEquals(0, exported.tasks.length); + assertEquals(0, exported.taskFingerprints.length); + assertEquals(0, exported.tags.length); + assertEquals(0, exported.categories.length); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java b/app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java new file mode 100644 index 00000000..81843373 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/encly/ExportedSnapshotStoreTest.java @@ -0,0 +1,105 @@ +package com.pasich.mynotes.utils.encly; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * What a hand-off carried is kept on disk instead of in the saved instance state, which a large + * library overflowed; it has to come back exactly, and only under its own token. + */ +public class ExportedSnapshotStoreTest { + + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + private static EnclyMigrationRepository.Exported snapshot(int records) { + long[] ids = new long[records]; + long[] prints = new long[records]; + for (int i = 0; i < records; i++) { + ids[i] = i + 1; + prints[i] = 31L * i - 7L; + } + return new EnclyMigrationRepository.Exported( + ids, + new long[] {5}, + new long[0], + new long[] {2, 3}, + prints, + new long[] {-1}, + new long[0], + new long[] {Long.MAX_VALUE, Long.MIN_VALUE}); + } + + @Test + public void aSavedSnapshot_readsBackExactly() throws IOException { + File dir = new File(folder.getRoot(), "encly"); + EnclyMigrationRepository.Exported saved = snapshot(50_000); + + String token = ExportedSnapshotStore.save(dir, saved); + EnclyMigrationRepository.Exported loaded = ExportedSnapshotStore.load(dir, token); + + assertNotNull(loaded); + assertArrayEquals(saved.notes, loaded.notes); + assertArrayEquals(saved.tasks, loaded.tasks); + assertArrayEquals(saved.tags, loaded.tags); + assertArrayEquals(saved.categories, loaded.categories); + assertArrayEquals(saved.noteFingerprints, loaded.noteFingerprints); + assertArrayEquals(saved.taskFingerprints, loaded.taskFingerprints); + assertArrayEquals(saved.tagFingerprints, loaded.tagFingerprints); + assertArrayEquals(saved.categoryFingerprints, loaded.categoryFingerprints); + } + + @Test + public void aNewHandoff_replacesTheOldSnapshot() throws IOException { + File dir = folder.getRoot(); + String first = ExportedSnapshotStore.save(dir, snapshot(3)); + + String second = ExportedSnapshotStore.save(dir, snapshot(4)); + + assertNull(ExportedSnapshotStore.load(dir, first)); + assertEquals(4, ExportedSnapshotStore.load(dir, second).notes.length); + } + + @Test + public void anUnknownOrMalformedToken_readsNothing() throws IOException { + File dir = folder.getRoot(); + ExportedSnapshotStore.save(dir, snapshot(3)); + + assertNull(ExportedSnapshotStore.load(dir, null)); + assertNull(ExportedSnapshotStore.load(dir, "1-abc")); + assertNull(ExportedSnapshotStore.load(dir, "../../etc/passwd")); + } + + @Test + public void aDamagedFile_readsNothing() throws IOException { + File dir = folder.getRoot(); + String token = ExportedSnapshotStore.save(dir, snapshot(3)); + File file = new File(dir, "exported-" + token + ".bin"); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(new byte[] {0, 0, 0, 1, 0x7f, 0x7f, 0x7f, 0x7f}); + } + + assertNull(ExportedSnapshotStore.load(dir, token)); + } + + @Test + public void aSweep_keepsSnapshotsSavedAfterItWasAskedFor() throws IOException { + File dir = folder.getRoot(); + String token = ExportedSnapshotStore.save(dir, snapshot(3)); + long savedAt = Long.parseLong(token.substring(0, token.indexOf('-'))); + + assertEquals(0, ExportedSnapshotStore.sweep(dir, savedAt)); + assertNotNull(ExportedSnapshotStore.load(dir, token)); + + assertEquals(1, ExportedSnapshotStore.sweep(dir, Long.MAX_VALUE)); + assertNull(ExportedSnapshotStore.load(dir, token)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java b/app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java new file mode 100644 index 00000000..2f12f679 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/file/ImageOptimizerTest.java @@ -0,0 +1,105 @@ +package com.pasich.mynotes.utils.file; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import androidx.exifinterface.media.ExifInterface; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.GraphicsMode; + +@RunWith(RobolectricTestRunner.class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = 35) +public class ImageOptimizerTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void sampleSize_leavesOrdinaryPhotosAlone() { + assertEquals(1, ImageOptimizer.sampleSizeFor(4000, 3000, 4096)); + assertEquals(1, ImageOptimizer.sampleSizeFor(4096, 100, 4096)); + } + + @Test + public void sampleSize_bringsHugePhotosUnderTheLimit() { + // 48 MP + assertEquals(2, ImageOptimizer.sampleSizeFor(8000, 6000, 4096)); + // 108 MP + assertEquals(4, ImageOptimizer.sampleSizeFor(12000, 9000, 4096)); + assertEquals(2, ImageOptimizer.sampleSizeFor(6000, 8192, 4096)); + assertEquals(4, ImageOptimizer.sampleSizeFor(6000, 8193, 4096)); + } + + @Test + public void orientationMatrix_isNullOnlyForUprightPictures() { + assertNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_NORMAL)); + assertNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_UNDEFINED)); + assertNotNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_ROTATE_90)); + assertNotNull(ImageOptimizer.orientationMatrix(ExifInterface.ORIENTATION_TRANSVERSE)); + } + + @Test + public void rotatedJpeg_isStoredUpright() throws Exception { + byte[] raw = jpeg(40, 20, ExifInterface.ORIENTATION_ROTATE_90); + + byte[] out = ImageOptimizer.optimizeRawImage(raw, ImageOptimizer.OutFormat.JPEG, false); + + BitmapFactory.Options bounds = decodeBounds(out); + assertEquals(20, bounds.outWidth); + assertEquals(40, bounds.outHeight); + } + + @Test + public void uprightJpeg_keepsItsSize() throws Exception { + byte[] raw = jpeg(40, 20, ExifInterface.ORIENTATION_NORMAL); + + byte[] out = ImageOptimizer.optimizeRawImage(raw, ImageOptimizer.OutFormat.JPEG, true); + + BitmapFactory.Options bounds = decodeBounds(out); + assertEquals(40, bounds.outWidth); + assertEquals(20, bounds.outHeight); + } + + @Test + public void undecodableBytes_comeBackUnchanged() { + byte[] raw = new byte[] {(byte) 0xFF, (byte) 0xD8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + + assertEquals( + raw, ImageOptimizer.optimizeRawImage(raw, ImageOptimizer.OutFormat.JPEG, false)); + } + + private byte[] jpeg(int width, int height, int orientation) throws Exception { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.eraseColor(Color.RED); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, bos); + File file = tmp.newFile("photo.jpg"); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(bos.toByteArray()); + } + ExifInterface exif = new ExifInterface(file.getAbsolutePath()); + exif.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(orientation)); + exif.saveAttributes(); + return Files.readAllBytes(file.toPath()); + } + + private static BitmapFactory.Options decodeBounds(byte[] data) { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, 0, data.length, bounds); + return bounds; + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java b/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java new file mode 100644 index 00000000..18956677 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/file/PdfLineWrapTest.java @@ -0,0 +1,101 @@ +package com.pasich.mynotes.utils.file; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +/** Line wrapping for the PDF export, measured as one unit per character. */ +public class PdfLineWrapTest { + + private static final FileExportUtils.TextWidth CHARS = String::length; + + @Test + public void shortLine_isDrawnAsIs() { + assertEquals(List.of("hello"), FileExportUtils.wrapLine("hello", 10, CHARS)); + } + + @Test + public void longLine_breaksBetweenWords() { + assertEquals( + Arrays.asList("aaa bbb", "ccc ddd"), + FileExportUtils.wrapLine("aaa bbb ccc ddd", 7, CHARS)); + } + + @Test + public void wordWiderThanTheLine_isBrokenNotDropped() { + List lines = FileExportUtils.wrapLine("abcdefghijkl", 5, CHARS); + + assertEquals(Arrays.asList("abcde", "fghij", "kl"), lines); + } + + @Test + public void overlongWordAfterOthers_startsItsOwnLine() { + assertEquals( + Arrays.asList("ab", "cdefgh", "ij xy"), + FileExportUtils.wrapLine("ab cdefghij xy", 6, CHARS)); + } + + @Test + public void noDrawnLineIsWiderThanAllowed() { + String text = "lorem ipsum dolorsitametconsectetur adipiscing elit sed do eiusmod"; + for (String line : FileExportUtils.wrapLine(text, 9, CHARS)) { + assertTrue(line, line.length() <= 9); + } + } + + @Test + public void anIndentedLine_keepsItsIndentOnEveryDrawnLine() { + assertEquals( + Arrays.asList(" aaa bbb", " ccc ddd"), + FileExportUtils.wrapLine(" aaa bbb ccc ddd", 11, CHARS)); + } + + @Test + public void anIndentedShortLine_isStillDrawnAsIs() { + assertEquals(List.of(" short"), FileExportUtils.wrapLine(" short", 10, CHARS)); + } + + @Test + public void anIndentedOverlongWord_isBrokenBesideTheIndent() { + List lines = FileExportUtils.wrapLine("\tabcdefgh", 4, CHARS); + + assertEquals(Arrays.asList("\tabc", "\tdef", "\tgh"), lines); + } + + @Test + public void anIndentWiderThanHalfTheLine_isCutSoTextStillFits() { + List lines = FileExportUtils.wrapLine(" abcd efgh", 8, CHARS); + + assertEquals(Arrays.asList(" abcd", " efgh"), lines); + for (String line : lines) { + assertTrue(line, line.length() <= 8); + } + } + + @Test + public void indentedSurrogatePairs_areNotSplit() { + String emoji = "😀"; + List lines = FileExportUtils.wrapLine(" " + emoji + emoji + emoji, 4, CHARS); + + for (String line : lines) { + assertTrue(line, line.startsWith(" ")); + assertTrue(!Character.isHighSurrogate(line.charAt(line.length() - 1))); + assertTrue(!Character.isLowSurrogate(line.charAt(2))); + } + } + + @Test + public void surrogatePairs_areNotSplit() { + String emoji = "😀"; // one character, two chars + List lines = FileExportUtils.wrapLine(emoji + emoji + emoji, 3, CHARS); + + for (String line : lines) { + assertTrue(!Character.isHighSurrogate(line.charAt(line.length() - 1))); + assertTrue(!Character.isLowSurrogate(line.charAt(0))); + } + assertEquals(emoji + emoji + emoji, String.join("", lines)); + } +} diff --git a/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java b/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java index 40f9b228..f704d1a9 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/managers/ThankedPurchasesTest.java @@ -52,4 +52,39 @@ public void forgetDropsTheToken() { assertThat(later.wasThanked("token-a")).isFalse(); assertThat(later.wasThanked("token-b")).isTrue(); } + + /** A purchase refunded or revoked before its consume never comes back: its token goes. */ + @Test + public void retainOnlyDropsTokensOfPurchasesThatAreGone() { + ThankedPurchases thanked = new ThankedPurchases(context); + thanked.markThanked("owned"); + thanked.markThanked("refunded"); + + java.util.Set recordedAtQuery = thanked.snapshot(); + + thanked.retainOnly(java.util.Set.of("owned", "never-thanked"), recordedAtQuery); + + ThankedPurchases later = new ThankedPurchases(context); + assertThat(later.wasThanked("owned")).isTrue(); + assertThat(later.wasThanked("refunded")).isFalse(); + assertThat(later.wasThanked("never-thanked")).isFalse(); + } + + /** + * A purchase thanked while the purchases query was in flight is missing from its answer + * although it exists. If its consume then fails, dropping its token here got it thanked again. + */ + @Test + public void retainOnlyKeepsTokensThankedAfterTheQueryWasSent() { + ThankedPurchases thanked = new ThankedPurchases(context); + thanked.markThanked("refunded"); + java.util.Set recordedAtQuery = thanked.snapshot(); + thanked.markThanked("bought-meanwhile"); + + thanked.retainOnly(java.util.Set.of(), recordedAtQuery); + + ThankedPurchases later = new ThankedPurchases(context); + assertThat(later.wasThanked("bought-meanwhile")).isTrue(); + assertThat(later.wasThanked("refunded")).isFalse(); + } } diff --git a/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java index 31a8d9f8..cd0923b1 100644 --- a/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java +++ b/app/src/test/java/com/pasich/mynotes/utils/reminder/ReminderReschedulerTest.java @@ -10,6 +10,7 @@ import com.pasich.mynotes.data.database.AppDatabase; import com.pasich.mynotes.data.model.Note; import com.pasich.mynotes.data.model.RepeatRule; +import com.pasich.mynotes.data.model.Task; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -75,6 +76,39 @@ private List alarmsFor(int noteId, String act return found; } + private int task(long time, int intervalMinutes) { + Task task = new Task("Task", 0); + task.setReminderTime(time); + task.setReminderIntervalMinutes(intervalMinutes); + return (int) db.taskDao().insertTask(task); + } + + private List taskAlarmsFor(int taskId) { + List found = new ArrayList<>(); + for (ShadowAlarmManager.ScheduledAlarm alarm : alarms.getScheduledAlarms()) { + Intent intent = shadowOf(alarm.operation).getSavedIntent(); + if (intent.getIntExtra(TaskReminderManager.EXTRA_TASK_ID, -1) == taskId) { + found.add(alarm); + } + } + return found; + } + + @Test + public void rescheduleAll_taskReminders_sameGraceAsNotes() { + int upcoming = task(NOW + HOUR, 0); + int recent = task(NOW - HOUR, 0); + int stale = task(NOW - 3 * 24 * HOUR, 0); + int repeating = task(NOW - 3 * 24 * HOUR, 15); + + rescheduler.rescheduleAll(NOW); + + assertThat(taskAlarmsFor(upcoming)).hasSize(1); + assertThat(taskAlarmsFor(recent)).hasSize(1); + assertThat(taskAlarmsFor(stale)).isEmpty(); + assertThat(taskAlarmsFor(repeating)).hasSize(1); + } + @Test public void rescheduleAll_armsUpcomingReminders() { int id = note(NOW + 2 * HOUR, "NONE", false); diff --git a/app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java b/app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java new file mode 100644 index 00000000..26bf7a76 --- /dev/null +++ b/app/src/test/java/com/pasich/mynotes/utils/shareProcessors/ShareProcessorTest.java @@ -0,0 +1,71 @@ +package com.pasich.mynotes.utils.shareProcessors; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.ContentResolver; +import android.net.Uri; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +/** The exported "open with" handler reads only other apps' content into a note. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35) +public class ShareProcessorTest { + + private static final String PACKAGE = "com.pasich.mynotes"; + + private static boolean readable(String uri) { + return ShareProcessor.isReadableSharedUri(uri == null ? null : Uri.parse(uri), PACKAGE); + } + + @Test + public void anotherAppsContentIsRead() { + assertThat(readable("content://com.android.providers.downloads.documents/document/12")) + .isTrue(); + assertThat(readable("content://com.pasich.mynotesfake/file.txt")).isTrue(); + } + + @Test + public void nothingFilesOrTheAppsOwnProvidersAreRefused() { + assertThat(readable(null)).isFalse(); + assertThat(readable("file:///data/data/com.pasich.mynotes/databases/notes.db")).isFalse(); + assertThat(readable("FILE:///sdcard/a.txt")).isFalse(); + assertThat(readable("content://com.pasich.mynotes.provider/files/a.txt")).isFalse(); + assertThat(readable("content://COM.PASICH.MYNOTES.provider/a")).isFalse(); + assertThat(readable("content://0@com.pasich.mynotes.provider/a")).isFalse(); + assertThat(readable("content://com.pasich.mynotes/a")).isFalse(); + assertThat(readable("https://example.com/a.txt")).isFalse(); + } + + @Test + public void aProviderWithNothingToReadReportsAnErrorInsteadOfCrashing() throws Exception { + ContentResolver resolver = RuntimeEnvironment.getApplication().getContentResolver(); + CountDownLatch done = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + ShareProcessor.readFileAsync( + resolver, + Uri.parse("content://com.example.missing/a.txt"), + new ShareProcessor.FileReadCallback() { + @Override + public void onSuccess(String content) { + done.countDown(); + } + + @Override + public void onError(Exception e) { + error.set(e); + done.countDown(); + } + }); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(error.get()).isNotNull(); + } +} diff --git a/notes_editor/src/custom/runtime.js b/notes_editor/src/custom/runtime.js index 33490977..342ca747 100644 --- a/notes_editor/src/custom/runtime.js +++ b/notes_editor/src/custom/runtime.js @@ -38,27 +38,17 @@ const __pageParams = new URLSearchParams(window.location.search) const __doubleTapToEdit = __pageParams.get('dbltap') === '1' /** - * Save current blocks and send to Android. + * Saves the blocks and sends them to Android unless they are what it already has. */ - function saveContent () { if (!editor) return editor .save() .then(output => { - const blocks = output.blocks - const jsonStr = JSON.stringify(blocks) - - // Якщо нічого не змінилось — ідемо спати - if (jsonStr === __lastSavedJson) { - return - } - - // Оновлюємо останнє збереження + const jsonStr = JSON.stringify(output.blocks) + if (jsonStr === __lastSavedJson) return __lastSavedJson = jsonStr - - // Шлемо в Android safeAndroidCall('onContentChanged', jsonStr) }) .catch(err => console.error('[Editor] Save failed:', err)) @@ -356,6 +346,8 @@ window.addEventListener('resize', () => requestAnimationFrame(keepCaretVisible)) const HISTORY_LIMIT = 100 const HISTORY_IDLE_MS = 700 const HISTORY_MAX_BATCH_MS = 3000 +// A finished word or line closes its batch after this pause. +const HISTORY_WORD_END_MS = 300 // Editor.js reports a change up to 400 ms after it happened; a report that late about a state // this history applied itself is not a new change. const HISTORY_SETTLE_MS = 600 @@ -371,6 +363,8 @@ const __hist = { lastCaret: null, batchStart: 0, timer: null, + // The timer closes the batch at the end of a word or line. + closing: false, applying: false, settleUntil: 0, epoch: 0, @@ -405,8 +399,16 @@ function readDocument () { })) } +/** + * A state is kept once, as JSON: keeping the parsed document too doubled the memory of up to + * HISTORY_LIMIT copies of the note. The document is parsed back only when a step is applied. + */ function historyEntry (doc, caret) { - return { doc, json: JSON.stringify(doc), caret } + return { json: JSON.stringify(doc), caret } +} + +function historyDoc (entry) { + return JSON.parse(entry.json) } function reportHistory () { @@ -446,6 +448,7 @@ function stopHistory () { __hist.epoch++ clearTimeout(__hist.timer) __hist.timer = null + __hist.closing = false __hist.undo = [] __hist.redo = [] __hist.current = null @@ -539,9 +542,15 @@ function resetHistory (settle) { /** * Something in the note changed, or is about to. {@code immediate} closes the batch now. - * {@code fromEditor} marks Editor.js's own late change report. + * {@code fromEditor} marks Editor.js's own late change report. {@code endsWord} closes the batch + * once typing pauses for a moment (HISTORY_WORD_END_MS) instead of at once. + * + * Closing a batch saves and serializes the whole note, so a finished word or line no longer does + * it on the spot: on a long note that cost was paid on every space. A brief pause still makes the + * word or line its own step; words typed without that pause become one step. Editor.js's late + * report of the same typing does not push a pending close back. */ -function historyNoteChange (immediate, fromEditor) { +function historyNoteChange (immediate, fromEditor, endsWord) { if (!__hist.current || __hist.applying || isReadMode) return if (fromEditor && Date.now() < __hist.settleUntil) return __hist.changeSeq++ @@ -551,9 +560,15 @@ function historyNoteChange (immediate, fromEditor) { __hist.batchStart = Date.now() if (!__hist.caretBefore) __hist.caretBefore = __hist.lastCaret } - clearTimeout(__hist.timer) const overdue = Date.now() - __hist.batchStart >= HISTORY_MAX_BATCH_MS - __hist.timer = setTimeout(captureHistory, immediate || overdue ? 0 : HISTORY_IDLE_MS) + if (!immediate && !overdue && fromEditor && __hist.closing && __hist.timer) { + reportHistory() + return + } + __hist.closing = !!endsWord && !immediate + clearTimeout(__hist.timer) + const delay = immediate || overdue ? 0 : endsWord ? HISTORY_WORD_END_MS : HISTORY_IDLE_MS + __hist.timer = setTimeout(captureHistory, delay) reportHistory() } @@ -570,6 +585,7 @@ function historyInputStarting () { function captureHistory (force) { clearTimeout(__hist.timer) __hist.timer = null + __hist.closing = false if (!editor || !__hist.current || (!__hist.pending && !force)) return Promise.resolve() const epoch = __hist.epoch const seq = __hist.changeSeq @@ -583,7 +599,7 @@ function captureHistory (force) { else __hist.batchHasText = false const entry = historyEntry(doc, historyCaret()) if (entry.json !== __hist.current.json) { - keepAttachmentsForUndo(__hist.current.doc, doc) + keepAttachmentsForUndo(historyDoc(__hist.current), doc) __hist.undo.push({ ...__hist.current, caret: caretBefore || __hist.current.caret }) if (__hist.undo.length > HISTORY_LIMIT) __hist.undo.shift() __hist.redo = [] @@ -662,8 +678,8 @@ function restoreHistoryCaret (caret, keep, fallbackId) { /** Puts the document of {@code entry} on screen with the smallest change that gets there. */ function applyHistoryEntry (entry) { - const from = __hist.current.doc - const to = entry.doc + const from = historyDoc(__hist.current) + const to = historyDoc(entry) const epoch = __hist.epoch __hist.applying = true keepAttachmentsForUndo(from, to) @@ -742,9 +758,10 @@ function historyRedo () { */ function forgetHistoryBlock (blockId) { const strip = entry => { - const blocks = entry.doc.blocks.filter(block => block.id !== blockId) - if (blocks.length === entry.doc.blocks.length) return entry - return historyEntry({ title: entry.doc.title, blocks }, entry.caret) + const doc = historyDoc(entry) + const blocks = doc.blocks.filter(block => block.id !== blockId) + if (blocks.length === doc.blocks.length) return entry + return historyEntry({ title: doc.title, blocks }, entry.caret) } const distinct = list => list.filter((entry, i) => i === 0 || entry.json !== list[i - 1].json) if (__hist.current) __hist.current = strip(__hist.current) @@ -793,7 +810,7 @@ document.addEventListener( } if (isEditingKey(event)) { historyInputStarting() - if (event.key === 'Enter') historyNoteChange(true, false) + if (event.key === 'Enter') historyNoteChange(false, false, true) } }, true @@ -815,7 +832,7 @@ document.addEventListener( const data = typeof event.data === 'string' ? event.data : '' const lineBreak = event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak' const endsWord = lineBreak || (/\s$/.test(data) && (__hist.batchHasText || /\S/.test(data))) - historyNoteChange(endsWord, false) + historyNoteChange(false, false, endsWord) if (/\S/.test(data)) __hist.batchHasText = true }, true @@ -837,6 +854,73 @@ function setThemeColors (colors) { } } +/** + * A plain-text note (from the simple editor, shared into the app or synced) as paragraph blocks. + * + * It used to become a single paragraph with a
per line. Every keystroke in a block makes the + * browser lay out that whole block again, so typing in a long note cost more the longer it was. + * + * Up to PLAIN_TEXT_LINE_BLOCKS lines of text, each line is a paragraph, as if it had been typed + * here with Enter. A longer note is cut into blocks of about PLAIN_TEXT_BLOCK_CHARS instead: every + * block holds DOM and a mutation observer of its own, and a block per line made a 1,300-line note + * take 70 MB more in the WebView. + * + * Android joins blocks back with a newline, so both give back exactly the text that came in. A + * blank line cannot be a block of its own (the editor drops empty blocks when it saves): per line, + * blank lines go in front of the next line as
; in kilobyte blocks, a cut is only made between + * two lines that hold text. Blank lines before the first and after the last line are left out, as + * Android trims them anyway. + */ +const PLAIN_TEXT_LINE_BLOCKS = 200 +const PLAIN_TEXT_BLOCK_CHARS = 1000 + +/** + * Plain text as paragraph HTML. The paragraph tool shows its text as HTML, so markup in a note + * (shared into the app, say) would otherwise run in the page, which can reach Android. + */ +function escapeHtml (text) { + return text.replace(/&/g, '&').replace(//g, '>') +} + +function plainTextBlocks (text) { + const lines = text.split('\n').map(escapeHtml) + while (lines.length && lines[0].trim() === '') lines.shift() + while (lines.length && lines[lines.length - 1].trim() === '') lines.pop() + const textLines = lines.filter(line => line.trim() !== '').length + const paragraph = text => ({ type: 'paragraph', data: { text } }) + + if (textLines <= PLAIN_TEXT_LINE_BLOCKS) { + const blocks = [] + let blanks = '' + for (const line of lines) { + if (line.trim() === '') { + blanks += line + '
' + } else { + blocks.push(paragraph(blanks + line)) + blanks = '' + } + } + return blocks + } + + const blocks = [] + let chunk = [] + let size = 0 + for (let i = 0; i < lines.length; i++) { + chunk.push(lines[i]) + size += lines[i].length + 1 + const next = lines[i + 1] + const canCut = next !== undefined && lines[i].trim() !== '' && next.trim() !== '' + if (size >= PLAIN_TEXT_BLOCK_CHARS && canCut) { + blocks.push(paragraph(chunk.join('
'))) + chunk = [] + size = 0 + } + } + if (chunk.length) blocks.push(paragraph(chunk.join('
'))) + return blocks +} + /** * Load note into editor */ @@ -849,12 +933,7 @@ function loadNote (note) { let blocks = [] if (note.plainTextFallback && note.plainText) { - blocks = [ - { - type: 'paragraph', - data: { text: note.plainText.replace(/\n/g, '
') } - } - ] + blocks = plainTextBlocks(note.plainText) } else if (note.valueJson) { blocks = note.valueJson } @@ -1177,6 +1256,7 @@ window.uploadAttachment = uploadAttachment window.toggleReadModeFromAndroid = toggleReadModeFromAndroid window.setReadMode = setReadMode window.saveContent = saveContent +window.plainTextBlocks = plainTextBlocks window.currentBlockIndex = currentBlockIndex window.insertUploadedBlockFromAndroid = insertUploadedBlockFromAndroid window.flushContent = flushContent diff --git a/notes_editor/test/history.test.mjs b/notes_editor/test/history.test.mjs index 1890a601..3ca8627b 100644 --- a/notes_editor/test/history.test.mjs +++ b/notes_editor/test/history.test.mjs @@ -87,3 +87,67 @@ test('the last edit is sent before the page reports it is finished', async () => assert.ok(names.lastIndexOf('onContentFlushed') < names.indexOf('onPageFinished')) assert.match(page.callsTo('onContentFlushed').at(-1)[0], /three/) }) + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +/** A loaded page whose editor counts how often the whole document is saved. */ +async function countingPage () { + const page = openPage() + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'one' } }]) + let saves = 0 + const save = editor.save + editor.save = () => { saves++; return save() } + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + return { page, state, saves: () => saves } +} + +/** Types {@code text} into the block, a character at a time, as beforeinput reports it. */ +function type (page, state, text) { + for (const ch of text) { + page.fire('document', 'beforeinput', { inputType: 'insertText', data: ch }) + state.blocks[0].data = { text: state.blocks[0].data.text + ch } + } +} + +test('a finished word becomes its own step after a short pause, not on the spot', async () => { + const { page, state, saves } = await countingPage() + const before = saves() + + type(page, state, ' two ') + await settle() + assert.equal(saves() - before, 0, 'the space does not save the whole note at once') + + await wait(350) + assert.equal(saves() - before, 1) + assert.equal(page.window.__internals.history.undo.length, 1) + + page.window.historyUndo() + await settle() + assert.equal(state.blocks[0].data.text, 'one') +}) + +test("Editor.js's late report of the typing does not push the word's step back", async () => { + const { page, state } = await countingPage() + + type(page, state, ' two ') + await wait(150) + page.window.historyEditorChanged({ type: 'block-changed' }) + await wait(250) + + assert.equal(page.window.__internals.history.undo.length, 1, 'closed 300 ms after the space') +}) + +test('a line break closes its step the same way', async () => { + const { page, state, saves } = await countingPage() + type(page, state, ' two') + const before = saves() + + page.fire('document', 'beforeinput', { inputType: 'insertParagraph' }) + await settle() + assert.equal(saves() - before, 0) + + await wait(350) + assert.equal(page.window.__internals.history.undo.length, 1) +}) diff --git a/notes_editor/test/save.test.mjs b/notes_editor/test/save.test.mjs new file mode 100644 index 00000000..177af3a3 --- /dev/null +++ b/notes_editor/test/save.test.mjs @@ -0,0 +1,144 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { openPage, fakeEditor, settle } from './page.mjs' + +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +async function loadedPage () { + const page = openPage() + const { editor, state } = fakeEditor([{ id: 'a', type: 'paragraph', data: { text: 'one' } }]) + let saves = 0 + const save = editor.save + editor.save = () => { saves++; return save() } + page.window.attachEditorInstance(editor) + page.window.loadNote({ title: 'T', valueJson: state.blocks }) + await settle() + return { page, state, saves: () => saves } +} + +test('a change is sent to Android at once', async () => { + const { page, state } = await loadedPage() + + state.blocks[0].data = { text: 'one two' } + page.window.saveContent() + await settle() + + const sent = page.callsTo('onContentChanged') + assert.equal(sent.length, 1) + assert.equal(JSON.parse(sent[0][0])[0].data.text, 'one two') +}) + +test('a flush sends only what Android does not have yet', async () => { + const { page, state } = await loadedPage() + + state.blocks[0].data = { text: 'one two' } + page.window.saveContent() + await settle() + await page.window.flushContent() + assert.equal(page.callsTo('onContentFlushed').length, 0, 'the same document is not sent twice') + + state.blocks[0].data = { text: 'one two three' } + await page.window.flushContent() + const flushed = page.callsTo('onContentFlushed') + assert.equal(flushed.length, 1) + assert.equal(JSON.parse(flushed[0][0])[0].data.text, 'one two three') +}) + +test('history states keep the document once, as JSON', async () => { + const { page, state } = await loadedPage() + + state.blocks[0].data = { text: 'one two' } + page.fire('document', 'beforeinput', { inputType: 'insertText', data: ' ' }) + page.window.historyEditorChanged({ type: 'block-changed' }) + await wait(800) + + const hist = page.window.__internals.history + assert.equal(hist.undo.length, 1) + for (const entry of [...hist.undo, hist.current]) { + assert.equal(entry.doc, undefined) + assert.equal(typeof entry.json, 'string') + } + + page.window.historyUndo() + await settle() + assert.equal(state.blocks[0].data.text, 'one') +}) + +/** What Android does when it turns the blocks back into plain text: texts joined with newlines. */ +const toPlain = blocks => blocks.map(b => b.data.text.replace(/
/g, '\n')).join('\n') + +test('a plain-text note opens with a block per line and gives back the same text', () => { + const page = openPage() + const lines = [] + for (let i = 0; i < 201; i++) lines.push(i % 7 === 3 ? '' : `paragraph ${i}: the quick brown fox`) + lines[10] = ' ' + const text = lines.join('\n') + + const blocks = page.window.plainTextBlocks(text) + + assert.equal(toPlain(blocks), text) + for (const block of blocks) { + assert.equal(block.type, 'paragraph') + const shown = block.data.text.split('
') + assert.notEqual(shown.at(-1).trim(), '', 'every block ends with a line of text, so none is empty') + for (const blank of shown.slice(0, -1)) assert.equal(blank.trim(), '', 'only blank lines lead a block') + } + assert.equal(blocks.length, lines.filter(l => l.trim() !== '').length) +}) + +test('blank lines become space before the next line', () => { + const page = openPage() + const blocks = page.window.plainTextBlocks('one\n\ntwo\nthree') + assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), ['one', '
two', 'three']) +}) + +test('blank lines around the note are left out, as Android trims them', () => { + const page = openPage() + const blocks = page.window.plainTextBlocks('\n\none\ntwo\n\n') + assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), ['one', 'two']) +}) + +test('a very long plain-text note opens in blocks of about a kilobyte that give back the same text', () => { + const page = openPage() + const lines = [] + for (let i = 0; i < 1300; i++) lines.push(i % 7 === 3 ? '' : `paragraph ${i}: the quick brown fox`) + const text = lines.join('\n') + + const blocks = page.window.plainTextBlocks(text) + + assert.equal(toPlain(blocks), text) + assert.ok(blocks.length > 20 && blocks.length < 120, `${blocks.length} blocks`) + for (const block of blocks) { + assert.ok(block.data.text.length < 2500, 'each block stays small') + assert.ok(!block.data.text.startsWith('
') && !block.data.text.endsWith('
'), + 'no block starts or ends with an empty line, which the editor could drop') + } +}) + +test('markup in a plain-text note is shown as text, never run as HTML', () => { + const page = openPage() + const text = '\n\nList a && b > c' + + const blocks = page.window.plainTextBlocks(text) + + assert.deepEqual(JSON.parse(JSON.stringify(blocks.map(b => b.data.text))), [ + '<img src=x onerror="Android.x()">', + '
List<String> a && b > c' + ]) + const decode = html => + html.replace(/
/g, '\n').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&') + assert.equal(blocks.map(b => decode(b.data.text)).join('\n'), text) +}) + +test('markup is escaped in a very long plain-text note too', () => { + const page = openPage() + const lines = [] + for (let i = 0; i < 300; i++) lines.push(`line ${i} & more`) + + const blocks = page.window.plainTextBlocks(lines.join('\n')) + + assert.ok(blocks.length > 1) + for (const block of blocks) { + assert.ok(!/<(?!br>)/.test(block.data.text), 'no tag other than
is left') + } +})