mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
Support local and OpenList libraries, metadata, independent media navigation, themes, and audio/video playback. Preserve approved v2 prototypes and current documentation; archive superseded review artifacts and keep generated validation output outside version control.
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import type { AndroidBridge, SpeechBridge } from '../android-bridge.ts';
|
|
import { FetchTransport } from './fetch-transport.ts';
|
|
import type { Connectivity, Platform } from './platform.ts';
|
|
import { createStores } from '../store/idb.ts';
|
|
import type { NativePageHost, NativePageRequest } from '../ui/native-page.ts';
|
|
|
|
/**
|
|
* The Android platform.
|
|
*
|
|
* A thin override over the web one, not a reimplementation. What the native
|
|
* shell is asked for is exactly the set of things a WebView genuinely cannot do
|
|
* or does badly:
|
|
*
|
|
* - **Connectivity.** `navigator.onLine` in an Android WebView reports "true"
|
|
* whenever a network interface exists. The native monitor reports an active
|
|
* network, including LAN-only connections; requests determine whether the
|
|
* configured server is reachable without requiring public-internet access.
|
|
* - **A stable device label.** The UA-derived one changes when Chrome updates.
|
|
* - **Native feedback.** A toast for "已下载" is one line of Kotlin and avoids
|
|
* building a notification UI in the web layer.
|
|
*
|
|
* Everything else — storage, transport, the sync engine — is shared, which is
|
|
* the entire point of building the client this way.
|
|
*
|
|
* The one thing this file knows that the web platform does not is the native
|
|
* page renderer: on Android a comic page is drawn by the platform, not by the
|
|
* WebView. `createNativePageHost` below is the whole of that special case, and
|
|
* it is written so that a refusal from the shell is indistinguishable from a
|
|
* browser with no native path at all.
|
|
*/
|
|
export async function createAndroidPlatform(baseUrl: string | (() => string), bridge: AndroidBridge): Promise<Platform> {
|
|
const stores = await createStores();
|
|
const listeners = new Set<(state: Connectivity) => void>();
|
|
let last: Connectivity = normalise(bridge.connectivity());
|
|
|
|
bridge.watchConnectivity((state) => {
|
|
const next = normalise(state);
|
|
if (next === last) return;
|
|
last = next;
|
|
for (const listener of listeners) listener(next);
|
|
});
|
|
|
|
return {
|
|
name: 'android',
|
|
deviceLabel: safeDeviceLabel(bridge),
|
|
transport: new FetchTransport(typeof baseUrl === 'function' ? baseUrl : () => baseUrl),
|
|
kv: stores.kv,
|
|
blobs: stores.blobs,
|
|
async connectivity(): Promise<Connectivity> {
|
|
try {
|
|
return normalise(bridge.connectivity());
|
|
} catch {
|
|
// A bridge that throws must not take the reader down with it.
|
|
return 'online';
|
|
}
|
|
},
|
|
onConnectivityChange(listener: (state: Connectivity) => void): () => void {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
reportError(error) {
|
|
// Offline is an expected state, reported in the status bar, not an event
|
|
// worth interrupting the reader for.
|
|
if (error.isConnectivity) return;
|
|
void error;
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalise(state: string): Connectivity {
|
|
return state === 'offline' ? 'offline' : 'online';
|
|
}
|
|
|
|
function safeDeviceLabel(bridge: AndroidBridge): string {
|
|
try {
|
|
const label = bridge.deviceLabel();
|
|
if (label && label.trim().length > 0) return label.trim().slice(0, 60);
|
|
} catch {
|
|
// Fall through to the generic label.
|
|
}
|
|
return 'android';
|
|
}
|
|
|
|
/**
|
|
* Feature detection for the native shell.
|
|
*
|
|
* Requires a minimum shell version: an old APK whose bridge is missing a method
|
|
* this client relies on would otherwise fail in a way that looks like a bug in
|
|
* the reader.
|
|
*/
|
|
export const MIN_SHELL_VERSION = 1;
|
|
|
|
/**
|
|
* First shell version with a native page renderer (`renderPage`/`hidePage`).
|
|
*
|
|
* Separate from `MIN_SHELL_VERSION` on purpose. The minimum is what the client
|
|
* cannot work without; this is an optimisation it can live without, so an older
|
|
* APK keeps working and simply draws every page in the WebView. Refusing to run
|
|
* on an old shell would be the wrong trade for a speed-up.
|
|
*/
|
|
export const NATIVE_PAGE_SHELL_VERSION = 2;
|
|
|
|
/**
|
|
* First shell version with a native `TextToSpeech` surface.
|
|
*
|
|
* A feature rather than a capability, like the native page renderer: a shell
|
|
* below 3 simply reads with the WebView's own synthesizer. Declared separately so
|
|
* the failure mode is understood — an old APK is not refused, it just does not
|
|
* appear in the朗读 engine list.
|
|
*/
|
|
export const NATIVE_SPEECH_SHELL_VERSION = 3;
|
|
|
|
export function detectAndroidBridge(): AndroidBridge | null {
|
|
if (typeof window === 'undefined') return null;
|
|
const bridge = window.ReaderAndroid;
|
|
if (!bridge || typeof bridge.shellVersion !== 'function') return null;
|
|
try {
|
|
if (bridge.shellVersion() < MIN_SHELL_VERSION) return null;
|
|
return promoteSpeechInterface(bridge);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The native speech engine, when the shell has one.
|
|
*
|
|
* Gated on the shell version like the page renderer, and for the same reason: a
|
|
* shell below 3 has no `speech` interface, and a client that called into it would
|
|
* fail in a way that looks like a reader bug rather than an old APK.
|
|
*/
|
|
export function androidSpeechBridge(bridge: AndroidBridge): SpeechBridge | null {
|
|
if (!shellAtLeast(bridge, NATIVE_SPEECH_SHELL_VERSION)) return null;
|
|
const speech = bridge.speech;
|
|
if (!speech || typeof speech.speak !== 'function') return null;
|
|
return speech;
|
|
}
|
|
|
|
/**
|
|
* The Android native renderer, exposed as a page host.
|
|
*
|
|
* Returns `null` when the shell is too old to have one, which is not an error:
|
|
* the view then draws every page itself, exactly as the browser does. Shell
|
|
* version 2 is the first one with `renderPage`.
|
|
*/
|
|
export function androidPageHost(bridge: AndroidBridge): NativePageHost | null {
|
|
if (!shellAtLeast(bridge, NATIVE_PAGE_SHELL_VERSION)) return null;
|
|
if (typeof bridge.renderPage !== 'function') return null;
|
|
|
|
return {
|
|
async show(request: NativePageRequest): Promise<boolean> {
|
|
if (request.mode === 'reflowable') return false;
|
|
// A PDF is declined for a reason worth stating: the shell's viewer is a
|
|
// separate screen, so drawing one would take the reader out of the app and
|
|
// lose the reading session. The WebView path stays.
|
|
if (request.mode === 'document') return false;
|
|
try {
|
|
return bridge.renderPage(serialisePage(request)) === true;
|
|
} catch {
|
|
// A bridge that throws must degrade to the WebView path, not blank the
|
|
// reading view. This is the only place that decides that, on purpose.
|
|
return false;
|
|
}
|
|
},
|
|
hide(): void {
|
|
try {
|
|
bridge.hidePage();
|
|
} catch {
|
|
// Nothing to undo.
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* JSON with bytes carried as base64, which is all a JS interface can accept.
|
|
*
|
|
* `mode` is passed through rather than inferred from the media type: the client
|
|
* decided what this section *is*, and re-deriving it on the other side of the
|
|
* bridge is how two implementations of the same rule start to disagree.
|
|
*/
|
|
export function serialisePage(request: NativePageRequest): string {
|
|
const payload: Record<string, unknown> = {
|
|
sectionId: request.sectionId,
|
|
mode: request.mode,
|
|
// Carried across so the native view honours the reader's preference rather
|
|
// than guessing: a page drawn "contain" after the reader chose "fit width"
|
|
// looks like the setting is broken.
|
|
fit: request.fit,
|
|
};
|
|
if (request.path) payload['path'] = request.path;
|
|
if (request.mediaType) payload['mediaType'] = request.mediaType;
|
|
if (request.bytes && request.bytes.byteLength > 0) payload['bytes'] = toBase64(request.bytes);
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
function toBase64(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
const chunk = 0x8000;
|
|
// Chunked: `String.fromCharCode(...bytes)` on a multi-megabyte page image
|
|
// overflows the argument list and throws a RangeError, which would look like
|
|
// "the native renderer does not work on big pages".
|
|
for (let offset = 0; offset < bytes.length; offset += chunk) {
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
/**
|
|
* Re-parents the flat speech interface into the object shape the client expects.
|
|
*
|
|
* `addJavascriptInterface` can expose objects, not properties of one, so the
|
|
* shell registers a second interface at `window.ReaderAndroidSpeech` (see
|
|
* `ReaderBridge.SPEECH_NAME`). Promoting it here keeps the contract in
|
|
* `android-bridge.d.ts` — one nested `speech` object — true from the web layer's
|
|
* point of view, so nothing above this file knows how the bridge happens to be
|
|
* wired on the platform side.
|
|
*
|
|
* Returns a new object rather than mutating the bridge: the bridge is created by
|
|
* the shell and may be shared, and a client that writes into it would be a client
|
|
* that can break the shell's own references.
|
|
*/
|
|
export function promoteSpeechInterface(bridge: AndroidBridge): AndroidBridge {
|
|
const raw = window.ReaderAndroidSpeech;
|
|
if (!raw) return bridge;
|
|
// Java bridge methods require the injected object as their receiver. A plain
|
|
// spread copies the functions but makes calls use the wrapper as `this`.
|
|
const speech = bindInjectedMethods(raw);
|
|
speech.onSpeechEvent = callback => registerNativeCallback(raw, 'onSpeechEvent', '__readerSpeechEvent', callback, true);
|
|
speech.onVoices = callback => registerNativeCallback(raw, 'onVoices', '__readerSpeechVoices', callback, true);
|
|
const promoted = { ...bridge, speech };
|
|
for (const key of Object.keys(bridge)) {
|
|
// Some shells also enumerate their native speech() accessor. The client
|
|
// contract requires the promoted object, not that Java accessor function.
|
|
if (key === 'speech') continue;
|
|
const method = Reflect.get(bridge, key);
|
|
if (typeof method === 'function') {
|
|
Reflect.set(promoted, key, method.bind(bridge));
|
|
}
|
|
}
|
|
promoted.watchConnectivity = callback => registerNativeCallback(bridge, 'watchConnectivity', '__readerConnectivity', callback, false);
|
|
return promoted;
|
|
}
|
|
|
|
function bindInjectedMethods<T extends object>(raw: T): T {
|
|
const result = { ...raw };
|
|
for (const key of Object.keys(raw)) {
|
|
const method = Reflect.get(raw, key);
|
|
if (typeof method === 'function') Reflect.set(result, key, method.bind(raw));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function registerNativeCallback<T>(raw: object, method: string, name: string, callback: (value: T) => void, json: boolean): void {
|
|
// Each native interface owns one listener. Reusing its name also replaces old
|
|
// listeners when a reading session creates a new speech engine.
|
|
Reflect.set(window, name, (value: string) => {
|
|
let parsed: T;
|
|
try { parsed = (json ? JSON.parse(value) : value) as T; } catch { return; }
|
|
callback(parsed);
|
|
});
|
|
Reflect.apply(Reflect.get(raw, method), raw, [name]);
|
|
}
|
|
|
|
function shellAtLeast(bridge: AndroidBridge, version: number): boolean {
|
|
try {
|
|
return bridge.shellVersion() >= version;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|