diff --git a/apps/readest-app/src/__tests__/android/double-click.android.test.ts b/apps/readest-app/src/__tests__/android/double-click.android.test.ts index 14c33748..7bf1416c 100644 --- a/apps/readest-app/src/__tests__/android/double-click.android.test.ts +++ b/apps/readest-app/src/__tests__/android/double-click.android.test.ts @@ -1,38 +1,45 @@ import path from 'node:path'; import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -import { doubleTap } from './helpers/adb'; import { CdpPage } from './helpers/cdp'; import { detectAndroidEnv, dismissSelection, getSelectionState, openFixtureBook, + patchGlobalViewSettings, waitFor, } from './helpers/reader'; // End-to-end coverage for the double-click / touch double-tap gesture: tapping a // word twice quickly selects that word — as if long-press-selecting it — and -// raises the annotation toolbar. With the default config no quick action is set +// raises the annotation toolbar. No quick action is set by default // (annotationQuickAction === null), so the toolbar (.selection-popup) appears. // -// Runs against any adb device/emulator with Readest installed; soft-skips -// otherwise. +// The feature is opt-in on mobile: DEFAULT_MOBILE_VIEW_SETTINGS ships +// disableDoubleClick: true (double-click detection delays single-tap page +// turns by the 250ms disambiguation window), so the lane seeds +// disableDoubleClick: false — the setting a user who wants the gesture turns +// on — and restores the previous value afterwards. +// +// Runs against any adb device/emulator with a debug Readest build installed; +// soft-skips otherwise. const FIXTURE = path.resolve(__dirname, '../fixtures/data/sample-alice.epub'); interface WordHit { word: string; - deviceX: number; - deviceY: number; + cssX: number; + cssY: number; } // Find a comfortably on-screen word (>= 4 latin letters, no adjacent apostrophe // so the native word matches the segmenter word), away from the page edges so -// the tap lands on text rather than a margin. +// the tap lands on text rather than a margin. The word must render as a single +// line box: a hyphenated/wrapped word's bounding rect spans both lines, so its +// center would tap the text between them and select a neighboring word. const locateAnyWord = (page: CdpPage) => page.evaluate(` const view = document.querySelector('foliate-view'); - const dpr = window.devicePixelRatio || 1; const W = window.innerWidth, H = window.innerHeight; for (const c of view.renderer.getContents()) { if (!c.doc) continue; @@ -48,14 +55,16 @@ const locateAnyWord = (page: CdpPage) => const range = c.doc.createRange(); range.setStart(node, m.index); range.setEnd(node, m.index + m[0].length); - const rect = range.getBoundingClientRect(); + const rects = range.getClientRects(); + if (rects.length !== 1) continue; + const rect = rects[0]; if (!rect.width || !rect.height) continue; const frame = c.doc.defaultView.frameElement.getBoundingClientRect(); const cssX = frame.left + rect.left + rect.width / 2; const cssY = frame.top + rect.top + rect.height / 2; if (cssX < W * 0.15 || cssX > W * 0.85) continue; if (cssY < H * 0.2 || cssY > H * 0.8) continue; - return { word: m[0], deviceX: Math.round(cssX * dpr), deviceY: Math.round(cssY * dpr) }; + return { word: m[0], cssX, cssY }; } } } @@ -72,14 +81,17 @@ if (!env) { describe.runIf(env)('Android double-tap word selection + toolbar', () => { let page: CdpPage; + let savedViewSettings: Record; beforeAll(async () => { + savedViewSettings = await patchGlobalViewSettings({ disableDoubleClick: false }); page = await openFixtureBook(FIXTURE); }, 120_000); - afterAll(() => { + afterAll(async () => { page?.close(); - }); + if (savedViewSettings) await patchGlobalViewSettings(savedViewSettings); + }, 60_000); beforeEach(async () => { const sel = await getSelectionState(page); @@ -89,7 +101,7 @@ describe.runIf(env)('Android double-tap word selection + toolbar', () => { it('selects the double-tapped word and shows the annotation toolbar', async () => { const hit = await waitFor(() => locateAnyWord(page), { label: 'on-screen word' }); - await doubleTap(hit.deviceX, hit.deviceY); + await page.doubleTap(hit.cssX, hit.cssY); const sel = await waitFor( async () => { diff --git a/apps/readest-app/src/__tests__/android/helpers/adb.ts b/apps/readest-app/src/__tests__/android/helpers/adb.ts index 90e8cc66..4c569e62 100644 --- a/apps/readest-app/src/__tests__/android/helpers/adb.ts +++ b/apps/readest-app/src/__tests__/android/helpers/adb.ts @@ -53,14 +53,6 @@ export const tap = (x: number, y: number): Promise => export const longPress = (x: number, y: number, ms = 700): Promise => adbShell(`input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${ms}`); -// Two taps in a single shell invocation so both `click` events reach the WebView -// inside the double-click window (DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250ms). -export const doubleTap = (x: number, y: number): Promise => { - const rx = Math.round(x); - const ry = Math.round(y); - return adbShell(`input tap ${rx} ${ry} && input tap ${rx} ${ry}`); -}; - export interface MotionStep { x: number; y: number; @@ -89,6 +81,16 @@ export const motionGesture = async (steps: MotionStep[]): Promise => { export const pushFile = (local: string, remote: string): Promise => adb(['push', local, remote]); +// App-private data files, via `run-as` — debug builds only (the package must +// be debuggable). Paths are relative to the app's data dir root. +export const readAppFile = (pkg: string, path: string): Promise => + adbShell(`run-as ${pkg} cat ${path}`); + +export const writeAppFile = async (pkg: string, path: string, content: string): Promise => { + const b64 = Buffer.from(content, 'utf8').toString('base64'); + await adbShell(`echo ${b64} | base64 -d | run-as ${pkg} sh -c 'cat > ${path}'`); +}; + export const forwardTcpToLocalAbstract = async (port: number, socket: string): Promise => { try { await adb(['forward', '--remove', `tcp:${port}`]); diff --git a/apps/readest-app/src/__tests__/android/helpers/cdp.ts b/apps/readest-app/src/__tests__/android/helpers/cdp.ts index 6ee4aaa5..75973e80 100644 --- a/apps/readest-app/src/__tests__/android/helpers/cdp.ts +++ b/apps/readest-app/src/__tests__/android/helpers/cdp.ts @@ -116,6 +116,26 @@ export class CdpPage { return res.result?.result?.value as T; } + /** + * Touch double-tap through the WebView's own input pipeline (gesture + * recognition and click synthesis included). adb `input tap` spawns a whole + * Java process per tap — 300ms to 1s on a loaded CI emulator — so two adb + * taps cannot reliably land inside the app's 250ms double-click window. + * A single tapCount:2 gesture is timed inside the renderer (measured click + * gap ~200ms on a busy emulator); two separate synthesizeTapGesture + * commands are too slow, since each resolves long after its gesture. + * Coordinates are CSS pixels. + */ + async doubleTap(cssX: number, cssY: number): Promise { + await this.send('Input.synthesizeTapGesture', { + x: cssX, + y: cssY, + tapCount: 2, + duration: 20, + gestureSourceType: 'touch', + }); + } + close(): void { this.ws.close(); } diff --git a/apps/readest-app/src/__tests__/android/helpers/reader.ts b/apps/readest-app/src/__tests__/android/helpers/reader.ts index 31149fc6..560c8928 100644 --- a/apps/readest-app/src/__tests__/android/helpers/reader.ts +++ b/apps/readest-app/src/__tests__/android/helpers/reader.ts @@ -6,7 +6,9 @@ import { listDeviceSerials, longPress, pushFile, + readAppFile, tap, + writeAppFile, } from './adb'; import { CdpPage, forwardWebViewDevtools, listPages } from './cdp'; @@ -49,6 +51,36 @@ export const waitFor = async ( const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** + * Patch `globalViewSettings` in the app's persisted settings.json and return + * the previous values of the patched keys (pass them back to restore). The app + * is force-stopped first so it cannot overwrite the patch on exit; the next + * launch reads the seeded settings. A missing settings.json (fresh install) is + * fine: loadSettings deep-merges the partial file over the platform defaults. + * Uses `run-as`, so it works on debug builds only — like the rest of the lane. + */ +export const patchGlobalViewSettings = async ( + patch: Record, +): Promise> => { + await adbShell(`am force-stop ${APP_PKG}`); + let settings: { globalViewSettings?: Record } = {}; + try { + settings = JSON.parse(await readAppFile(APP_PKG, 'settings.json')); + } catch { + // fresh install — seed a minimal settings file + } + settings.globalViewSettings ??= {}; + const viewSettings = settings.globalViewSettings; + const previous: Record = {}; + for (const [key, value] of Object.entries(patch)) { + previous[key] = viewSettings[key]; + if (value === undefined) delete viewSettings[key]; + else viewSettings[key] = value; + } + await writeAppFile(APP_PKG, 'settings.json', JSON.stringify(settings)); + return previous; +}; + /** * Open a fixture EPUB in the reader via a VIEW intent (the "Open with" flow, * which opens the book transiently without touching the user's library) and @@ -471,7 +503,20 @@ export const longPressWord = async ( /** Tap an empty-ish text spot away from the popup to dismiss the selection. */ export const dismissSelection = async (page: CdpPage): Promise => { const { dpr, viewWidth, viewHeight } = await getReaderMetrics(page); - await tap(viewWidth * 0.5 * dpr, viewHeight * 0.78 * dpr); + // The annotation toolbar (.selection-popup) renders near the selection — + // wherever that is. A tap that lands on the toolbar presses a button instead + // of dismissing, so pick whichever mid-column spot the popup doesn't cover. + const dismissY = await page.evaluate(` + const popup = document.querySelector('.selection-popup'); + const candidates = [0.78, 0.25]; + if (!popup) return candidates[0]; + const rect = popup.getBoundingClientRect(); + const clear = candidates.find( + (f) => ${viewHeight} * f < rect.top - 24 || ${viewHeight} * f > rect.bottom + 24, + ); + return clear ?? candidates[0]; + `); + await tap(viewWidth * 0.5 * dpr, viewHeight * dismissY * dpr); await waitFor( async () => { const handles = await getCustomHandles(page);