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: 3 additions & 1 deletion app/note/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,9 @@ export default function NoteScreen() {

<PageSidebar
visible={isPageSidebarOpen}
pages={enginePages}
pages={pagesForOverlay}
backgroundType={metadata?.backgroundType ?? 'plain'}
pdfUri={metadata?.pdfUri}
currentPage={currentPageIndex}
topInset={headerHeight}
bottomInset={insets.bottom}
Expand Down
4 changes: 4 additions & 0 deletions ios/OpenNotes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */; };
7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */; };
7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */; };
7D3A801D2E1A4CC89B1B09C1 /* ImageThumbnailEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A801C2E1A4CC89B1B09C1 /* ImageThumbnailEncoder.swift */; };
9C2FD7C620026BB779139CCD /* libPods-OpenNotes.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A7A85B485F4321855B927960 /* libPods-OpenNotes.a */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
Expand All @@ -29,6 +30,7 @@
7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PDFUtilsModule.m; path = OpenNotes/PDFUtilsModule.m; sourceTree = "<group>"; };
7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ICloudBackupModule.swift; path = OpenNotes/ICloudBackupModule.swift; sourceTree = "<group>"; };
7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ICloudBackupModule.m; path = OpenNotes/ICloudBackupModule.m; sourceTree = "<group>"; };
7D3A801C2E1A4CC89B1B09C1 /* ImageThumbnailEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ImageThumbnailEncoder.swift; path = OpenNotes/ImageThumbnailEncoder.swift; sourceTree = "<group>"; };
9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OpenNotes/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
A7A85B485F4321855B927960 /* libPods-OpenNotes.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OpenNotes.a"; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OpenNotes/SplashScreen.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -60,6 +62,7 @@
7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */,
7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */,
7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */,
7D3A801C2E1A4CC89B1B09C1 /* ImageThumbnailEncoder.swift */,
F11748442D0722820044C1D9 /* OpenNotes-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
Expand Down Expand Up @@ -348,6 +351,7 @@
7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */,
7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */,
7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */,
7D3A801D2E1A4CC89B1B09C1 /* ImageThumbnailEncoder.swift in Sources */,
4788858903E15B3CAEB129E6 /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
37 changes: 37 additions & 0 deletions ios/OpenNotes/ImageThumbnailEncoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers

enum ImageThumbnailEncoder {
enum DecodeError: Error {
case invalidSource
case decodeFailed
case encodeFailed
}

/// Produces bounded, orientation-correct preview bytes without modifying the asset.
static func pngData(at url: URL, maxPixelSize: Int = 1024) throws -> Data {
guard url.isFileURL, maxPixelSize > 0,
let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
throw DecodeError.invalidSource
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceShouldCacheImmediately: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
throw DecodeError.decodeFailed
}
let data = CFDataCreateMutable(nil, 0)!
guard let destination = CGImageDestinationCreateWithData(
data, UTType.png.identifier as CFString, 1, nil
) else {
throw DecodeError.encodeFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else { throw DecodeError.encodeFailed }
return data as Data
}
}
4 changes: 4 additions & 0 deletions ios/OpenNotes/PDFUtilsModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

@interface RCT_EXTERN_MODULE(PDFUtilsModule, NSObject)

RCT_EXTERN_METHOD(readImageForThumbnail:(NSString *)fileUri
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)

RCT_EXTERN_METHOD(getPageCount:(NSString *)filePath
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
Expand Down
20 changes: 20 additions & 0 deletions ios/OpenNotes/PDFUtilsModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ class PDFUtilsModule: NSObject {
return false
}

@objc
func readImageForThumbnail(_ fileUri: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
DispatchQueue.global(qos: .userInitiated).async {
autoreleasepool {
guard let url = PDFUtilsModule.localFileURL(fileUri) else {
rejecter("E_INVALID_PATH", "Invalid image file URL", nil)
return
}
do {
let data = try ImageThumbnailEncoder.pngData(at: url)
resolver("data:image/png;base64," + data.base64EncodedString())
} catch {
rejecter("E_IMAGE_DECODE", "Could not decode image for thumbnail", error)
}
}
}
}

/// A file URL for a file:// URI or a plain absolute path.
private static func localFileURL(_ path: String) -> URL? {
if path.hasPrefix("file://") {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
"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",
"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/noteThumbnails.test.mjs scripts/thumbnailRendering.test.mjs",
"test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs",
"test:thumbnail-native": "xcrun swiftc ios/OpenNotes/ImageThumbnailEncoder.swift scripts/imageThumbnailSmoke.swift -o /tmp/opennotes-image-thumbnail-smoke && /tmp/opennotes-image-thumbnail-smoke",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
Expand Down
15 changes: 15 additions & 0 deletions scripts/catalogStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import {
parseCatalog,
} from '../src/services/catalogStore.ts';

test('catalog reload preserves composed thumbnail versions and still accepts old notes', () => {
const catalog = parseCatalog(JSON.stringify({
version: 1, folders: [], notes: [
{ id: 'old', title: 'Old note', thumbnailUri: 'old-ink-preview' },
{ id: 'new', title: 'Photo note', thumbnailUri: 'composed-cover', thumbnailVersion: 1 },
],
}));
assert.equal(catalog.notes[0].thumbnailUri, 'old-ink-preview');
assert.equal(catalog.notes[0].thumbnailVersion, undefined);
assert.equal(catalog.notes[1].thumbnailVersion, 1);
const restored = parseCatalog(JSON.stringify(catalog));
assert.equal(restored.notes[1].thumbnailUri, 'composed-cover');
assert.equal(restored.notes[1].thumbnailVersion, 1);
});

function makeKv(initial = {}) {
const map = new Map(Object.entries(initial));
return {
Expand Down
60 changes: 60 additions & 0 deletions scripts/imageThumbnailSmoke.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers

@main
struct ImageThumbnailSmoke {
static func main() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }

let context = CGContext(data: nil, width: 2000, height: 1000,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.setFillColor(CGColor(red: 0, green: 0.7, blue: 0.2, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: 2000, height: 1000))
let image = context.makeImage()!
var checked = 0
for (name, type, orientation) in [
("photo.png", UTType.png, 1),
("photo.jpg", UTType.jpeg, 6),
("photo.heic", UTType.heic, 1),
] {
let url = directory.appendingPathComponent(name)
let destination = CGImageDestinationCreateWithURL(url as CFURL, type.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(destination, image, [kCGImagePropertyOrientation: orientation] as CFDictionary)
precondition(CGImageDestinationFinalize(destination), "Could not create fixture")
let before = try Data(contentsOf: url)
let thumbnail = try ImageThumbnailEncoder.pngData(at: url, maxPixelSize: 256)
let source = CGImageSourceCreateWithData(thumbnail as CFData, nil)!
precondition(CGImageSourceGetType(source) as String? == UTType.png.identifier)
let decoded = CGImageSourceCreateImageAtIndex(source, 0, nil)!
precondition(decoded.width == (orientation == 6 ? 128 : 256), "Wrong thumbnail width")
precondition(decoded.height == (orientation == 6 ? 256 : 128), "Wrong thumbnail height/orientation")
var pixel = [UInt8](repeating: 0, count: 4)
let sample = CGContext(data: &pixel, width: 1, height: 1, bitsPerComponent: 8,
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
sample.draw(decoded, in: CGRect(x: 0, y: 0, width: 1, height: 1))
precondition(pixel[0] < 40 && pixel[1] > 140 && pixel[2] < 100, "Image colors were lost")
let after = try Data(contentsOf: url)
precondition(after == before, "Original photo was changed")
checked += 1
}
for url in [directory.appendingPathComponent("missing.heic"), directory.appendingPathComponent("broken.png")] {
if url.lastPathComponent == "broken.png" { try Data("invalid".utf8).write(to: url) }
do {
_ = try ImageThumbnailEncoder.pngData(at: url)
preconditionFailure("Invalid input should be rejected")
} catch { checked += 1 }
}
do {
_ = try ImageThumbnailEncoder.pngData(at: URL(string: "https://example.com/image.heic")!)
preconditionFailure("Remote URLs must not be fetched by the native helper")
} catch { checked += 1 }
FileHandle.standardOutput.write(Data("Passed \(checked) native image thumbnail checks (PNG, JPEG orientation, HEIC, missing/corrupt assets).\n".utf8))
}
}
167 changes: 167 additions & 0 deletions scripts/noteThumbnails.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';

const source = ts.transpileModule(
readFileSync(new URL('../src/services/notesRepo.ts', import.meta.url), 'utf8'),
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } },
).outputText;

const ink = 'data:image/png;base64,aW5r';
const composite = 'data:image/png;base64,cGhvdG8=';
const photo = { id: 'photo', type: 'image', sourceUri: 'file:///photo.jpg', x: 20, y: 30, width: 100, height: 80 };
const body = () => ({ version: '1.0', pages: [{ id: 'page-1', data: '{"pages":{}}', previewUri: ink, insertedElements: [photo] }] });

function harness({ writeOk = true, renderFails = false, render = async () => composite, readKind = 'ok' } = {}) {
let catalog = { notes: [{ id: 'note', createdAt: '2026-01-01', updatedAt: '2026-01-02', thumbnailUri: ink, backgroundType: 'plain' }] };
let stored = body();
let reads = 0;
const exports = {};
vm.runInNewContext(source, {
exports,
require(name) {
if (name === './catalogEnv') return { catalogStore: {
getCatalog: async () => structuredClone(catalog),
mutate: async fn => { catalog = fn(catalog) ?? catalog; return catalog; },
} };
if (name === './noteBodyStorage') return {
writeBody: async (_id, data) => { if (writeOk) stored = structuredClone(data); return writeOk; },
readBody: async () => { reads += 1; return { kind: readKind, data: structuredClone(stored) }; },
};
if (name === './thumbnailService') return {
THUMBNAIL_VERSION: 1,
createPageThumbnail: async (...args) => {
if (renderFails) throw new Error('Image decoder unavailable');
return render(...args);
},
};
if (name === '../i18n') return { t: { common: { untitled: 'Untitled' } } };
if (name === '../utils/id') return {};
if (name === './catalogStore') return {};
if (name === './pdfStorage' || name === './imageInsertStorage') return {};
throw new Error('Unexpected import: ' + name);
},
});
return { ...exports, note: () => catalog.notes[0], stored: () => stored, reads: () => reads,
removeNote: () => { catalog.notes = []; } };
}

test('saving uses a composed cover without flattening overlays into the note body', async () => {
const h = harness();
const original = body();
const result = await h.saveNoteBody('note', original);
assert.equal(result.ok, true);
assert.equal(h.note().thumbnailUri, composite);
assert.equal(h.note().thumbnailVersion, 1);
assert.deepEqual(h.stored(), original);
assert.equal(h.stored().pages[0].previewUri, ink);
});

test('failed body writes leave the previous cover and metadata untouched', async () => {
const h = harness({ writeOk: false });
const before = structuredClone(h.note());
assert.equal((await h.saveNoteBody('note', body())).ok, false);
assert.deepEqual(h.note(), before);
});

test('thumbnail failure cannot fail a note save or discard its images', async () => {
const h = harness({ renderFails: true });
assert.equal((await h.saveNoteBody('note', body())).ok, true);
assert.equal(h.stored().pages[0].insertedElements[0].sourceUri, photo.sourceUri);
assert.equal(h.note().thumbnailUri, ink);
assert.notEqual(h.note().thumbnailVersion, 1);
});

test('legacy thumbnail repair leaves the note body and modification date untouched', async () => {
const h = harness();
const before = structuredClone(h.stored());
assert.equal(await h.getNoteThumbnail(h.note()), composite);
assert.equal(h.note().updatedAt, '2026-01-02');
assert.equal(h.note().thumbnailVersion, 1);
assert.deepEqual(h.stored(), before);
});

test('failed legacy thumbnail repair keeps the existing cover eligible for retry', async () => {
const h = harness({ renderFails: true });
assert.equal(await h.getNoteThumbnail(h.note()), ink);
assert.notEqual(h.note().thumbnailVersion, 1);
});

test('image-only saves compose the first page even when no ink preview exists', async () => {
const h = harness({ render: async page => page.insertedElements?.length ? composite : null });
const original = body();
delete original.pages[0].previewUri;
assert.equal((await h.saveNoteBody('note', original)).ok, true);
assert.equal(h.note().thumbnailUri, composite);
assert.equal(h.stored().pages[0].previewUri, undefined);
});

test('saving an image-only edit uses the new overlay data instead of the previous body', async () => {
const h = harness({ render: async page => page.insertedElements[0].x === 300 ? composite : ink });
const original = body();
original.pages[0].insertedElements = [{ ...photo, x: 300 }];
await h.saveNoteBody('note', original);
assert.equal(h.note().thumbnailUri, composite);
});

test('deleting all overlays restores the original ink-only cover', async () => {
const h = harness({ render: async page => page.insertedElements?.length ? composite : page.previewUri });
await h.saveNoteBody('note', body());
const original = body();
original.pages[0].insertedElements = [];
await h.saveNoteBody('note', original);
assert.equal(h.note().thumbnailUri, ink);
});

for (const readKind of ['unreadable', 'missing']) {
test(`${readKind} legacy bodies retain their last thumbnail without being rewritten`, async () => {
const h = harness({ readKind });
const before = structuredClone(h.note());
assert.equal(await h.getNoteThumbnail(h.note()), ink);
assert.deepEqual(h.note(), before);
});
}

test('current thumbnail versions do not re-read or rewrite legacy bodies', async () => {
const h = harness();
await h.updateMetadata('note', { thumbnailVersion: 1, thumbnailUri: composite });
assert.equal(await h.getNoteThumbnail(h.note()), composite);
assert.equal(h.reads(), 0);
});

test('an old thumbnail repair cannot overwrite a newer note save', async () => {
let finishRender;
let startRender;
const started = new Promise(resolve => { startRender = resolve; });
const h = harness({ render: () => new Promise(resolve => { finishRender = resolve; startRender(); }) });
const repairing = h.getNoteThumbnail(structuredClone(h.note()));
await started;
await h.updateMetadata('note', { updatedAt: '2026-10-22', thumbnailUri: 'new-cover', thumbnailVersion: 1 });
finishRender(composite);
assert.equal(await repairing, 'new-cover');
assert.equal(h.note().thumbnailUri, 'new-cover');
});

test('a repair completing after note deletion cannot resurrect the note', async () => {
let finishRender;
let startRender;
const started = new Promise(resolve => { startRender = resolve; });
const h = harness({ render: () => new Promise(resolve => { finishRender = resolve; startRender(); }) });
const repairing = h.getNoteThumbnail(h.note());
await started;
h.removeNote();
finishRender(composite);
assert.equal(await repairing, null);
assert.equal(h.note(), undefined);
});

test('duplicate card requests share a single repair of the same legacy cover', async () => {
const h = harness();
const first = h.getNoteThumbnail(h.note());
const second = h.getNoteThumbnail(h.note());
assert.equal(await first, composite);
assert.equal(await second, composite);
assert.equal(h.reads(), 1);
});
Loading
Loading