Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/app-expo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";

import { MobileFallbackExtractorHost } from "@/components/rag/MobileFallbackExtractorHost";
import { AnimatedSplash } from "@/components/splash/AnimatedSplash";
import { rnSessionEventSource } from "@/hooks";
import { setStreamingFetch } from "@readany/core/ai/llm-provider";
Expand Down Expand Up @@ -290,6 +291,7 @@ function AppInner() {
<StatusBar style={isDark ? "light" : "dark"} />
<RootNavigator />
</NavigationContainer>
<MobileFallbackExtractorHost />
<UpdateDialog />
<FloatingTTSBubble />
</SafeAreaProvider>
Expand Down
20 changes: 18 additions & 2 deletions packages/app-expo/src/components/rag/ExtractorWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ export const ExtractorWebView = forwardRef<ExtractorRef>((_, ref) => {
if (!htmlUri) return null;

return (
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<View style={styles.host} pointerEvents="none">
<WebView
ref={webViewRef}
source={{ uri: htmlUri }}
style={{ width: 0, height: 0, opacity: 0 }}
style={styles.webView}
originWhitelist={["*"]}
javaScriptEnabled
domStorageEnabled
Expand All @@ -161,3 +161,19 @@ export const ExtractorWebView = forwardRef<ExtractorRef>((_, ref) => {
</View>
);
});

const styles = StyleSheet.create({
host: {
position: "absolute",
left: 0,
bottom: 0,
width: 1,
height: 1,
overflow: "hidden",
opacity: 0.01,
},
webView: {
width: 1,
height: 1,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createMobileFallbackContentProvider } from "@/lib/rag/mobile-fallback-content-provider";
import { setFallbackContentProvider } from "@readany/core/ai";
import { getPlatformService } from "@readany/core/services";
import { useEffect, useRef } from "react";
import { type ExtractorRef, ExtractorWebView } from "./ExtractorWebView";

export function MobileFallbackExtractorHost() {
const extractorRef = useRef<ExtractorRef>(null);

useEffect(() => {
setFallbackContentProvider(
createMobileFallbackContentProvider({
getExtractor: () => extractorRef.current,
platform: getPlatformService(),
}),
);

return () => setFallbackContentProvider(null);
}, []);

return <ExtractorWebView ref={extractorRef} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const componentDir = dirname(fileURLToPath(import.meta.url));
const srcDir = resolve(componentDir, "../..");

describe("mobile fallback extractor ownership", () => {
it("mounts the fallback host beside root navigation", () => {
const appSource = readFileSync(resolve(srcDir, "App.tsx"), "utf8");

expect(appSource).toContain("import { MobileFallbackExtractorHost }");
expect(appSource).toMatch(/<RootNavigator\s*\/>[\s\S]*<MobileFallbackExtractorHost\s*\/>/);
});

it("does not register the AI fallback provider from LibraryScreen", () => {
const librarySource = readFileSync(resolve(srcDir, "screens/LibraryScreen.tsx"), "utf8");

expect(librarySource).not.toContain("setFallbackContentProvider");
});

it("keeps the extraction WebView non-zero-sized", () => {
const extractorSource = readFileSync(resolve(componentDir, "ExtractorWebView.tsx"), "utf8");

expect(extractorSource).not.toMatch(/width:\s*0|height:\s*0/);
expect(extractorSource).toMatch(/width:\s*1/);
expect(extractorSource).toMatch(/height:\s*1/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import { createMobileFallbackContentProvider } from "./mobile-fallback-content-provider";

const book = {
id: "book-1",
filePath: "books/book-1.epub",
format: "epub",
meta: { title: "Book 1" },
} as const;

function makeDependencies(overrides: Record<string, unknown> = {}) {
const extractChapters = vi.fn(async () => [
{ index: 0, title: "Chapter 1", content: "Text", segments: [] },
]);
return {
dependencies: {
getExtractor: () => ({ extractChapters }),
platform: {
getAppDataDir: vi.fn(async () => "/app-data"),
joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
exists: vi.fn(async () => true),
readFile: vi.fn(async () => new Uint8Array([1, 2, 3])),
},
...overrides,
},
extractChapters,
};
}

describe("mobile fallback content provider", () => {
it("resolves a relative local book and extracts it with the matching MIME type", async () => {
const { dependencies, extractChapters } = makeDependencies();
const provider = createMobileFallbackContentProvider(dependencies);

await expect(provider.getChapters(book as never)).resolves.toEqual([
{ index: 0, title: "Chapter 1", content: "Text", segments: [] },
]);
expect(dependencies.platform.joinPath).toHaveBeenCalledWith("/app-data", "books/book-1.epub");
expect(dependencies.platform.exists).toHaveBeenCalledWith("/app-data/books/book-1.epub");
expect(extractChapters).toHaveBeenCalledWith("AQID", "application/epub+zip");
});

it("rejects remote files before trying to read them", async () => {
const { dependencies } = makeDependencies();
const provider = createMobileFallbackContentProvider(dependencies);

await expect(
provider.getChapters({ ...book, filePath: "https://example.com/book.epub" } as never),
).rejects.toThrow("requires a local book file");
expect(dependencies.platform.readFile).not.toHaveBeenCalled();
});

it("reports a missing local file without invoking the extractor", async () => {
const { dependencies, extractChapters } = makeDependencies({
platform: {
getAppDataDir: vi.fn(async () => "/app-data"),
joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
exists: vi.fn(async () => false),
readFile: vi.fn(async () => new Uint8Array()),
},
});
const provider = createMobileFallbackContentProvider(dependencies);

await expect(provider.getChapters(book as never)).rejects.toThrow(
"Book file is not available on this device",
);
expect(extractChapters).not.toHaveBeenCalled();
});
});
70 changes: 70 additions & 0 deletions packages/app-expo/src/lib/rag/mobile-fallback-content-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ExtractorRef } from "@/components/rag/ExtractorWebView";
import type { FallbackContentProvider } from "@readany/core/ai";
import type { IPlatformService } from "@readany/core/services";

type MobileFallbackPlatform = Pick<
IPlatformService,
"exists" | "getAppDataDir" | "joinPath" | "readFile"
>;

interface MobileFallbackContentProviderDependencies {
getExtractor: () => ExtractorRef | null;
platform: MobileFallbackPlatform;
}

const MIME_TYPES: Record<string, string> = {
epub: "application/epub+zip",
pdf: "application/pdf",
mobi: "application/x-mobipocket-ebook",
azw: "application/vnd.amazon.ebook",
azw3: "application/vnd.amazon.ebook",
cbz: "application/vnd.comicbook+zip",
cbr: "application/vnd.comicbook+zip",
fb2: "application/x-fictionbook+xml",
fbz: "application/x-zip-compressed-fb2",
txt: "text/plain",
};

function bytesToBase64(bytes: Uint8Array): string {
const chunkSize = 0x8000;
let binary = "";

for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}

return btoa(binary);
}

function isAbsoluteBookPath(filePath: string): boolean {
return /^(?:\/|file:\/\/|asset:\/\/|https?:\/\/)/i.test(filePath);
}

export function createMobileFallbackContentProvider(
dependencies: MobileFallbackContentProviderDependencies,
): FallbackContentProvider {
return {
async getChapters(book) {
const extractor = dependencies.getExtractor();
if (!extractor) throw new Error("Mobile fallback extractor is not ready");

const { platform } = dependencies;
const filePath = isAbsoluteBookPath(book.filePath)
? book.filePath
: await platform.joinPath(await platform.getAppDataDir(), book.filePath);
if (/^https?:\/\//i.test(filePath)) {
throw new Error("Mobile original-file search requires a local book file");
}
if (!(await platform.exists(filePath))) {
throw new Error("Book file is not available on this device");
}

const bytes = await platform.readFile(filePath);
const format = String(book.format || "").toLowerCase();
return extractor.extractChapters(
bytesToBase64(bytes),
MIME_TYPES[format] || "application/epub+zip",
);
},
};
}
52 changes: 0 additions & 52 deletions packages/app-expo/src/screens/LibraryScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,11 @@ import {
type WebDavImportSource,
getPlatformService,
} from "@readany/core";
import { setFallbackContentProvider } from "@readany/core/ai";
import { onLibraryChanged } from "@readany/core/events/library-events";
import { useSyncStore } from "@readany/core/stores";
import { SYNC_SECRET_KEYS } from "@readany/core/sync/sync-backend";
import type { Book, BookGroup, SortField } from "@readany/core/types";
import * as DocumentPicker from "expo-document-picker";
import { File as ExpoFile } from "expo-file-system";
/**
* LibraryScreen — matching Tauri mobile LibraryPage exactly.
* Features: header search/sort/import, tag filter, vectorization progress banner,
Expand Down Expand Up @@ -80,17 +78,6 @@ import { TagManagementSheet } from "./library/TagManagementSheet";
import { useBookDownload } from "./library/useBookDownload";
import { useVectorizationQueue } from "./library/useVectorizationQueue";

function bytesToBase64(bytes: Uint8Array): string {
const chunkSize = 0x8000;
let binary = "";

for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}

return btoa(binary);
}

const BOOK_PNG = require("../../assets/book.png");
const BOOK_DARK_PNG = require("../../assets/book-dark.png");

Expand Down Expand Up @@ -259,52 +246,13 @@ export function LibraryScreen() {

useEffect(() => {
setExtractorRef(extractorRef.current);
setFallbackContentProvider({
async getChapters(book) {
if (!extractorRef.current) throw new Error("Mobile fallback extractor is not ready");
const platform = getPlatformService();
const appData = await platform.getAppDataDir();
const filePath =
book.filePath.startsWith("/") ||
book.filePath.startsWith("file://") ||
book.filePath.startsWith("asset://") ||
book.filePath.startsWith("http")
? book.filePath
: await platform.joinPath(appData, book.filePath);
if (/^https?:\/\//i.test(filePath)) {
throw new Error("Mobile original-file search requires a local book file");
}

const file = new ExpoFile(filePath);
if (!file.exists) throw new Error("Book file is not available on this device");

const bytes = await platform.readFile(filePath);
const mimeTypes: Record<string, string> = {
epub: "application/epub+zip",
pdf: "application/pdf",
mobi: "application/x-mobipocket-ebook",
azw: "application/vnd.amazon.ebook",
azw3: "application/vnd.amazon.ebook",
cbz: "application/vnd.comicbook+zip",
cbr: "application/vnd.comicbook+zip",
fb2: "application/x-fictionbook+xml",
fbz: "application/x-zip-compressed-fb2",
txt: "text/plain",
};
return extractorRef.current.extractChapters(
bytesToBase64(bytes),
mimeTypes[String(book.format || "").toLowerCase()] || "application/epub+zip",
);
},
});
setCallback((bookId, progress) => {
console.log(
`[AutoVectorize] Book ${bookId}: ${progress.status} (${Math.round(progress.progress * 100)}%)`,
);
});
return () => {
setExtractorRef(null);
setFallbackContentProvider(null);
setCallback(null);
};
}, []);
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/ai/__tests__/fallback-content-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,63 @@ describe("fallbackContentService", () => {

await pending;
});

it("shares one provider request between concurrent reads of the same book", async () => {
let resolveProvider:
| ((chapters: Array<{ index: number; title: string; content: string }>) => void)
| undefined;
const providerRequest = new Promise<Array<{ index: number; title: string; content: string }>>(
(resolve) => {
resolveProvider = resolve;
},
);
const getChapters = vi.fn(() => providerRequest);
setFallbackContentProvider({ getChapters });

const first = fallbackContentService.getChapters(book);
const second = fallbackContentService.getChapters(book);
const chapters = [{ index: 0, title: "Chapter 1", content: "Text" }];
resolveProvider?.(chapters);

await expect(first).resolves.toBe(chapters);
await expect(second).resolves.toBe(chapters);
expect(getChapters).toHaveBeenCalledTimes(1);
});

it("clears a failed in-flight request so a later read can retry", async () => {
const chapters = [{ index: 0, title: "Chapter 1", content: "Text" }];
const getChapters = vi
.fn()
.mockRejectedValueOnce(new Error("Extractor failed"))
.mockResolvedValueOnce(chapters);
setFallbackContentProvider({ getChapters });

await expect(fallbackContentService.getChapters(book)).rejects.toThrow("Extractor failed");
await expect(fallbackContentService.getChapters(book)).resolves.toBe(chapters);
expect(getChapters).toHaveBeenCalledTimes(2);
});

it("does not let an old provider completion replace the new provider cache", async () => {
let resolveOldProvider:
| ((chapters: Array<{ index: number; title: string; content: string }>) => void)
| undefined;
setFallbackContentProvider({
getChapters: () =>
new Promise((resolve) => {
resolveOldProvider = resolve;
}),
});
const oldRequest = fallbackContentService.getChapters(book);

const newChapters = [{ index: 0, title: "New chapter", content: "New text" }];
const newProvider = vi.fn(async () => newChapters);
setFallbackContentProvider({ getChapters: newProvider });
await expect(fallbackContentService.getChapters(book)).resolves.toBe(newChapters);

const oldChapters = [{ index: 0, title: "Old chapter", content: "Old text" }];
resolveOldProvider?.(oldChapters);
await expect(oldRequest).resolves.toBe(oldChapters);
await expect(fallbackContentService.getChapters(book)).resolves.toBe(newChapters);
expect(newProvider).toHaveBeenCalledTimes(1);
});
});
Loading