Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"expo": {
"name": "OpenNotes",
"slug": "open-notes",
"version": "1.3.4",
"version": "1.3.5",
"scheme": "opennotes",
"orientation": "default",
"icon": "./assets/icon.png",
Expand All @@ -16,7 +16,7 @@
"ios": {
"bundleIdentifier": "com.builderpro.opennotes",
"icon": "./assets/icon.png",
"buildNumber": "17",
"buildNumber": "18",
"supportsTablet": true,
"usesIcloudStorage": true,
"entitlements": {
Expand Down
8 changes: 4 additions & 4 deletions ios/OpenNotes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = U2CPXQV7AJ;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
Expand All @@ -467,7 +467,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.3.5;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand All @@ -493,15 +493,15 @@
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = U2CPXQV7AJ;
INFOPLIST_FILE = OpenNotes/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.4;
MARKETING_VERSION = 1.3.5;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down
4 changes: 2 additions & 2 deletions ios/OpenNotes/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.3.4</string>
<string>1.3.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
Expand All @@ -54,7 +54,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>17</string>
<string>18</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
Expand Down
19 changes: 19 additions & 0 deletions ios/OpenNotesUITests/NotebookInteractionUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,25 @@ final class NotebookInteractionUITests: XCTestCase {
}
}

// Each pause outlasts the 350 ms autosave debounce, so every stroke after the
// first is drawn while the dense 100-page document is being captured.
func testDenseWritingDuringAutosavePerformance() throws {
let app = prepare()
let viewport = element(app, "ink-viewport")
tap(app, "Enable finger drawing")
try measureInteractions(app) {
for row in 0..<6 {
let y = 0.3 + Double(row) * 0.08
let start = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: y))
let end = viewport.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: y + 0.02))
start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .default, thenHoldForDuration: 0)
Thread.sleep(forTimeInterval: 0.4)
}
for _ in 0..<6 { tap(app, "Undo") }
}
assertDenseInkVisible(app, context: "Writing during autosave complete")
}

func testDenseScrollAndZoomPerformance() throws {
let app = prepare()
let viewport = element(app, "ink-viewport")
Expand Down
4 changes: 2 additions & 2 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ PODS:
- hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5)
- hermes-engine/Pre-built (0.81.5)
- MathNotesMobileInk (0.3.8):
- MathNotesMobileInk (0.3.9):
- React-Core
- react-native-skia
- RCTDeprecation (0.81.5)
Expand Down Expand Up @@ -2552,7 +2552,7 @@ SPEC CHECKSUMS:
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
MathNotesMobileInk: 84fb87a4ff80b818202db8b5fbb7ac8bbdcd419c
MathNotesMobileInk: aba86894baa524180e07594ec138906806630991
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
Expand Down
34 changes: 27 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@
"android": "expo run:android",
"test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs",
"test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs",
"test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs scripts/continuousAssets.test.mjs scripts/overlayHistory.test.mjs scripts/overlayGeometry.test.mjs scripts/nativeCallback.test.mjs",
"test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs scripts/appDocumentUri.test.mjs scripts/crashReport.test.mjs scripts/continuousAssets.test.mjs scripts/overlayHistory.test.mjs scripts/overlayGeometry.test.mjs scripts/nativeCallback.test.mjs scripts/autosaveQueue.test.mjs",
"test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@mathnotes/mobile-ink": "0.3.8",
"@mathnotes/mobile-ink": "0.3.9",
"@react-native-async-storage/async-storage": "2.2.0",
"@shopify/react-native-skia": "2.2.12",
"expo": "54.0.34",
Expand Down
88 changes: 88 additions & 0 deletions scripts/autosaveQueue.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {createAutosaveQueue} from '../src/hooks/autosaveQueue.ts';

const wait = ms => new Promise(done => setTimeout(done, ms));
const superseded = error => error.message === 'Document changed during capture';
const harness = (save) => {
const statuses = [];
const queue = createAutosaveQueue({save, onStatus: s => statuses.push(s), isEnabled: () => true, debounceMs: 5,
isSuperseded: superseded});
return {queue, statuses};
};

test('a change during a save waits for it instead of writing concurrently', async () => {
const order = [];
let release;
let calls = 0;
const {queue} = harness(async () => {
const call = ++calls;
order.push(`start ${call}`);
if (call === 1) await new Promise(done => {release = done;});
order.push(`end ${call}`);
});
queue.schedule();
await wait(15);
queue.schedule();
await wait(15);
assert.deepEqual(order, ['start 1']);
release();
await queue.flushNow();
assert.deepEqual(order, ['start 1', 'end 1', 'start 2', 'end 2']);
});

test('a save invalidated by newer ink reports pending, then saves the newer ink', async () => {
let calls = 0;
let queueRef;
const {queue, statuses} = harness(async () => {
if (++calls === 1) {
queueRef.schedule();
throw new Error('Document changed during capture');
}
});
queueRef = queue;
queue.schedule();
await wait(15);
assert.equal(statuses.includes('error'), false);
await wait(15);
await queue.flushNow();
assert.equal(calls, 2);
assert.equal(statuses.at(-1), 'saved');
});

test('a failure with no newer change is reported and retried on flush', async () => {
let calls = 0;
const {queue, statuses} = harness(async () => {
if (++calls === 1) throw new Error('disk full');
});
queue.schedule();
await wait(15);
assert.equal(statuses.at(-1), 'error');
assert.equal(await queue.flushNow(), true);
assert.equal(calls, 2);
assert.equal(statuses.at(-1), 'saved');
});

test('a real failure is reported even while newer ink is pending', async () => {
let calls = 0;
let queueRef;
const {queue, statuses} = harness(async () => {
if (++calls === 1) {
queueRef.schedule();
throw new Error('disk full');
}
});
queueRef = queue;
queue.schedule();
await wait(15);
assert.ok(statuses.includes('error'));
await queue.flushNow();
assert.equal(statuses.at(-1), 'saved');
});

test('flush without changes does not save', async () => {
let calls = 0;
const {queue} = harness(async () => { calls++; });
assert.equal(await queue.flushNow(), true);
assert.equal(calls, 0);
});
86 changes: 86 additions & 0 deletions src/hooks/autosaveQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export type AutosaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error';

export interface AutosaveQueueOptions {
save: () => Promise<void>;
onStatus: (status: AutosaveStatus) => void;
isEnabled: () => boolean;
debounceMs: number;
onError?: (error: unknown) => void;
/** True when newer edits invalidated the save; their own schedule retries it. */
isSuperseded?: (error: unknown) => boolean;
}

/**
* Debounced saves that never overlap. A save captures the document first and
* writes it last, so two concurrent saves could land out of order and let an
* older capture overwrite newer ink on disk.
*/
export function createAutosaveQueue({ save, onStatus, isEnabled, debounceMs, onError, isSuperseded }: AutosaveQueueOptions) {
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight: Promise<boolean> | null = null;
let dirty = false;

const clearTimer = () => {
if (timer) clearTimeout(timer);
timer = null;
};

const runSave = async (): Promise<boolean> => {
if (!isEnabled()) return true;
// Cleared before the save so changes made while it runs mark dirty again.
dirty = false;
onStatus('saving');
try {
await save();
onStatus(dirty ? 'pending' : 'saved');
return true;
} catch (error) {
dirty = true;
if (isSuperseded?.(error)) {
onStatus('pending');
return false;
}
onError?.(error);
onStatus('error');
return false;
}
};

const start = (): Promise<boolean> => {
const previous = inFlight;
const promise = (async () => {
if (previous) await previous;
if (!dirty) return true;
return runSave();
})();
inFlight = promise;
void promise.finally(() => {
if (inFlight === promise) inFlight = null;
});
return promise;
};

return {
schedule() {
if (!isEnabled()) return;
dirty = true;
onStatus('pending');
clearTimer();
timer = setTimeout(() => {
timer = null;
void start();
}, debounceMs);
},
flushNow(): Promise<boolean> {
clearTimer();
return start();
},
cancelPending() {
clearTimer();
onStatus('idle');
},
dispose() {
clearTimer();
},
};
}
Loading
Loading