commit 65328113176649b94e0a13b75ae6fc9db6dc7b98
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-14T03:41:15Z |
| subject | initial commit |
commit 65328113176649b94e0a13b75ae6fc9db6dc7b98
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-14T03:41:15Z
initial commit
---
.gitignore | 8 +
LICENSE | 21 +
README.md | 202 +++++++++
THIRD_PARTY_NOTICES.md | 20 +
app/build.gradle | 25 ++
app/src/main/AndroidManifest.xml | 29 ++
app/src/main/assets/reader/content.js | 450 +++++++++++++++++++++
app/src/main/assets/reader/manifest.json | 34 ++
app/src/main/assets/reader/motion.js | 31 ++
.../net/astropenguin/naroureader/MainActivity.java | 351 ++++++++++++++++
.../naroureader/ReaderApplication.java | 25 ++
.../net/astropenguin/naroureader/TiltSensor.java | 83 ++++
app/src/main/res/drawable/ic_reader.xml | 4 +
app/src/main/res/values/styles.xml | 10 +
build.gradle | 1 +
build.sh | 11 +
gradle.properties | 4 +
gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48749 bytes
gradle/wrapper/gradle-wrapper.properties | 10 +
gradlew | 248 ++++++++++++
gradlew.bat | 112 +++++
settings.gradle | 11 +
signing/README.md | 9 +
tests/motion.test.cjs | 15 +
tests/reader.test.cjs | 104 +++++
25 files changed, 1818 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..61b0e60
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.gradle/
+.idea/
+local.properties
+**/build/
+*.iml
+*.keystore
+*.jks
+*.swp
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..14fac91
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5b81052
--- /dev/null
+++ b/README.md
@@ -0,0 +1,202 @@
+# 縦書き Reader for Android
+
+A standalone, immersive Narou reader powered by Mozilla GeckoView. The vertical
+reading extension is bundled inside the APK; Firefox and Mozilla add-on signing
+are not needed.
+
+## Install
+
+Use `narou-reader-1.1.0-arm64.apk` on a 64-bit ARM Android phone such as a Pixel.
+Requires Android 8.0 (API 26) or newer. Download the APK on the phone, open it,
+and allow that browser/file manager to install apps when Android asks.
+
+This is a development-signed build for personal use, not a Play Store release.
+The download is about 99 MiB because it includes the Firefox engine. Keep this
+source archive, including `signing/debug.keystore`, for compatible future updates.
+
+## Read
+
+The first launch opens the chapter supplied in the original request:
+https://ncode.syosetu.com/n0552ep/75/
+
+- Swipe right to advance through the columns; swipe left to go back.
+- Tap the text to show or hide controls. Long-press still selects text.
+- **文字**: font size; **行間**: column spacing; **段落**: paragraph spacing.
+- **配色**: paper, white or dark theme.
+- **自動**: open the reader automatically on chapter pages.
+- **読書に戻る**: hide controls without changing the text layout.
+- **元のページ**: show the original website; **縦書き** reopens the reader.
+- **前へ / 目次 / 次へ**: chapter navigation.
+- **☰**: open another Narou URL, reload, share, open in a browser, or view help.
+- Android Back goes back through browsing history, then closes the Activity.
+- Swipe from the system edge to temporarily reveal Android's system bars.
+
+The screen is reserved for the text while reading. Controls overlay the page,
+so opening them does not change the text layout. Camera cutouts are respected.
+
+Paragraphs containing only `<br>` elements or whitespace are removed from the
+reading copy. Empty span wrappers around breaks are handled too. Paragraphs
+containing illustrations remain. Paragraph spacing is independent of column
+spacing, with a 0–2em control and a default of 0.3em.
+
+The app remembers the last Narou URL and saves normalized horizontal progress
+for up to 200 chapter URLs. Position, font, column/paragraph spacing, theme and
+auto-opening preference are local to this app. Progress is approximate when the
+layout or chapter text changes. This version reads online; it does not download
+whole books for offline use.
+
+You can share a Narou chapter URL from another app to **縦書き Reader**. Opening
+Narou links directly in the app may require enabling supported links in Android
+App Info, because this personal app does not own or verify the site's domain.
+
+## Build on macOS / Linux / Windows
+
+Install a current Android Studio, or OpenJDK 27 (JDK 17 also works) and the Android command-line tools.
+Install SDK Platform 37.2 and Build Tools 37.0.0 through SDK Manager. In Android
+Studio open this directory, or set `ANDROID_HOME` to your SDK location.
+
+The project uses Gradle 9.8.1, Android Gradle Plugin 9.4.1, and GeckoView
+158.0.20261007201254. The Gradle wrapper is included. No machine-specific SDK
+path is included in the source archive.
+
+```sh
+./build.sh # Build only; no adb/device required
+./build.sh --install # Build, then install on one connected device
+./build.sh --serial DEVICE # Build, then install on the selected device
+./gradlew :app:lintDebug # Optional Android lint check
+```
+
+`build.sh` uses your existing `JAVA_HOME` or Java from `PATH`, including OpenJDK 27.
+It stops immediately on a failed build. Installation is opt-in; it checks for an
+authorized device and selects ARM64 or x86_64 automatically. The Android bytecode
+target remains Java 17, independently of the JDK used to run Gradle.
+
+On Windows use `gradlew.bat`. Output:
+
+```text
+app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
+app/build/outputs/apk/debug/app-x86_64-debug.apk
+```
+
+The ARM64 build is for current phones; x86_64 is for emulator testing. Native
+libraries are compressed in the APK to reduce download size. The included
+development key is deliberately usable by the project; see `signing/README.md`.
+Generate and protect a separate release key before any public distribution.
+
+## Where to edit
+
+- `app/src/main/java/net/astropenguin/naroureader/MainActivity.java`: app UI,
+ immersive mode, URL navigation, back handling, sharing and native menus.
+- `ReaderApplication.java` alongside it: process-wide Gecko runtime and extension installation.
+- `app/src/main/assets/reader/content.js`: reading layout, text cleanup, controls,
+ native touch scrolling and saved chapter positions.
+- `app/src/main/assets/reader/manifest.json`: bundled extension registration.
+
+The browser engine is bundled, so updating Firefox on the phone does not update
+this app. Update the pinned GeckoView dependency and rebuild to ship engine fixes.
+
+## Validation and limits
+
+- Both ARM64 and x86_64 debug APKs compiled successfully.
+- Android lint completed with no errors. Warnings concern the intentionally
+ conservative target API (35), unverified website links, and Android backup configuration.
+- ARM64 APK signature, ZIP alignment, native architecture, minimum SDK, and
+ bundled reader asset identity verified.
+- Reader behavior tested with Firefox 153 against the supplied chapter and a
+ separate synthetic fixture. Tests cover spacer removal, separate paragraph
+ spacing, native-message requests, tapping controls without text reflow,
+ full-height layout, persisted settings/progress, reload, rotation and toggling
+ back to the original page. WebExtension storage and native messaging are
+ simulated in these browser tests.
+- A headless Android emulator was attempted but failed to boot reliably in the
+ build environment. **The APK has not been run on a physical phone or a working
+ emulator.** Device behavior, including gesture navigation and Android-native
+ dialogs, still needs an on-device check.
+
+To run the bundled reader regression test, install Node.js and Playwright in a
+temporary development environment, then run:
+
+```sh
+npm install --no-save playwright
+npx playwright install firefox
+node tests/reader.test.cjs
+```
+
+The test uses synthetic Japanese text; no copied novel is included. Japanese fonts
+are needed for meaningful visual screenshots on Linux.
+
+## Privacy and licenses
+
+No app analytics or telemetry is added. Network access loads the original website
+and its resources; its normal cookies and requests still apply. This app has its
+own browser profile and does not share Firefox's cookies or logged-in accounts.
+The reader is injected only on `https://ncode.syosetu.com/`. Other Syosetu pages
+can be browsed normally; clicked external HTTPS links open in another browser.
+
+App code is MIT-licensed. GeckoView is MPL 2.0 with third-party notices; see
+`THIRD_PARTY_NOTICES.md` and app menu → About / help → Engine licenses.
+
+## JDK 27 build update
+
+Gradle 9.6 cannot run on JDK 27 and fails with `Unsupported class file major
+version 71`. This project now pins Gradle 9.8.1, including its distribution
+SHA-256 and regenerated wrapper files. No JDK downgrade is required.
+
+For an existing checkout, copy the updated `gradle/wrapper/` directory, `gradlew`,
+`gradlew.bat`, and `build.sh` from this archive. Preserve your application code
+and signing key. If you maintain your own script, ensure it exits after a failed
+Gradle command instead of continuing to adb.
+
+Verified with a clean ARM64/x86_64 build and Android lint using Amazon Corretto
+OpenJDK 27+35-FR and Gradle 9.8.1 on Linux. App class files remain Java 17
+(major version 61). Build-script checks cover compilation failure, build-only
+mode, missing devices, and ABI selection for installation.
+
+## Version 1.0.1 — camera cutout theme
+
+The native Android window and camera-cutout safe area now follow the selected
+paper, white or dark reader theme. The native theme is restored at startup, and
+system-bar icon contrast follows the theme when the bars are temporarily shown.
+The bundled extension version is also bumped so installed apps load the update.
+
+Install this APK over the existing app to keep settings and reading positions.
+It uses the same development signing key. When updating an existing source build,
+run `./gradlew :app:clean` once before `./build.sh --install` to remove stale APK
+packaging state. The updated reader regression verifies theme messages on first
+load, theme changes and saved-theme restoration.
+
+## Version 1.1.0 — accelerometer scrolling
+
+Ported from your Windows AccelerScroll.cs and ReaderContent.xaml.cs. This uses
+sideways accelerometer tilt (including gravity), not gyroscope angular velocity.
+The acceleration → capped velocity → neutral-zone braking behavior is retained.
+Values now use CSS pixels/second and pixels/second², rather than pixels/timer tick.
+The neutral zone is symmetric around the calibrated position, correcting the old
+offset-dependent range. The old auto-anchor option is replaced by the reader's
+existing saved position, checkpointed every two seconds while motion scrolling.
+
+1. Tap text to show controls. Tap **傾き: OFF** to enable it.
+2. Hold the phone at your comfortable reading angle; tap **Set neutral**.
+3. Tap **読書に戻る**. Tilt sideways to accelerate, tilt the other way to reverse,
+ or return to neutral to brake. Tap the text to stop immediately.
+4. Expand **Tilt scrolling** to adjust acceleration, maximum speed, neutral-zone
+ width, braking strength, or reverse direction. Controls pause scrolling.
+
+Tuning preferences and the enabled switch persist (default OFF).
+Opening a chapter, enabling motion, or rotating captures a fresh neutral angle; calibration
+is intentionally session-only so an old grip does not unexpectedly start scrolling.
+Touch/selection pauses motion. Closing the reader, losing app focus, backgrounding,
+missing sensor samples or navigation stops it. The Android listener has a one-second
+watchdog and unregisters when unused. Screen-on is requested only while moving.
+No new Android permission is required. Devices without an accelerometer show an
+unavailable message; ordinary swipe reading still works.
+
+Implementation: `TiltSensor.java` collects filtered, display-relative samples;
+`motion.js` holds the acceleration/braking model; `content.js` owns calibration,
+controls, animation, and native polling. Android sensor coordinates follow the
+[Android sensor documentation](https://developer.android.com/develop/sensors-and-location/sensors/sensors_overview).
+
+Run `node tests/motion.test.cjs` for physics checks and `node tests/reader.test.cjs`
+for Firefox integration checks with a simulated native accelerometer. The latter
+covers movement, pausing, reversing, inactive samples, rotation and settings.
+Actual sensor feel and device lifecycle still require a phone check.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..aa65a0d
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,20 @@
+# Third-party software
+
+The APK embeds Mozilla GeckoView 158.0.20261007201254. GeckoView is distributed
+under the Mozilla Public License 2.0 and includes components under other open
+source licenses. The engine's complete license page is accessible through the
+app menu → About / help → Engine licenses (`about:license`).
+
+- Engine project: https://firefox-source-docs.mozilla.org/mobile/android/geckoview/
+- MPL 2.0: https://www.mozilla.org/MPL/2.0/
+- Corresponding release source:
+ https://hg.mozilla.org/releases/mozilla-release/rev/eb9a6e3a736802c8e080a76c44dc81f409d99d54
+- Artifact metadata:
+ https://maven.mozilla.org/maven2/org/mozilla/geckoview/geckoview/158.0.20261007201254/geckoview-158.0.20261007201254.pom
+
+The engine is unmodified. AndroidX libraries are distributed under Apache 2.0;
+Kotlin standard library under Apache 2.0. Their sources and licensing are
+available in the respective Google Maven / Maven Central artifacts.
+
+App and reading-view source: MIT, see LICENSE. Novel text and illustrations
+belong to their authors. No novel content is bundled in the APK or source.
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..b83936d
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,25 @@
+plugins { id 'com.android.application' }
+android {
+ namespace 'net.astropenguin.naroureader'
+ buildFeatures { buildConfig = true }
+ compileSdk { version = release(37) { minorApiLevel = 2 } }
+ buildToolsVersion '37.0.0'
+ defaultConfig {
+ applicationId 'net.astropenguin.naroureader'
+ minSdk 26
+ targetSdk 35
+ versionCode 3
+ versionName '1.1.0'
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ signingConfigs { debug { storeFile rootProject.file('signing/debug.keystore'); storePassword 'android'; keyAlias 'androiddebugkey'; keyPassword 'android' } }
+ splits { abi { enable = true; reset(); include 'arm64-v8a', 'x86_64'; universalApk = false } }
+ buildTypes { release { minifyEnabled false } }
+ packaging { jniLibs { useLegacyPackaging = true } }
+}
+dependencies {
+ implementation 'org.mozilla.geckoview:geckoview:158.0.20261007201254'
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..80559f0
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.INTERNET" />
+ <application android:name=".ReaderApplication" android:label="縦書き Reader"
+ android:icon="@drawable/ic_reader" android:theme="@style/AppTheme"
+ android:allowBackup="false" android:usesCleartextTraffic="false"
+ android:supportsRtl="true">
+ <activity android:name=".MainActivity" android:exported="true"
+ android:launchMode="singleTop"
+ android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
+ android:windowSoftInputMode="stateUnspecified|adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="https" android:host="ncode.syosetu.com" />
+ </intent-filter>
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="text/plain" />
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
diff --git a/app/src/main/assets/reader/content.js b/app/src/main/assets/reader/content.js
new file mode 100644
index 0000000..b5dfaf9
--- /dev/null
+++ b/app/src/main/assets/reader/content.js
@@ -0,0 +1,450 @@
+(() => {
+ 'use strict';
+ if (document.getElementById('narou-tategaki-host')) return;
+ const sources = [...document.querySelectorAll('.p-novel__text')];
+ if (!sources.length) sources.push(...document.querySelectorAll('#novel_p, #novel_honbun, #novel_a'));
+ if (!sources.length) return; // Leave table-of-contents and other pages alone.
+
+ const motionDefaults = {acceleration: 900, speed: 120, deadzone: .08, brake: .15, reverse: false};
+ let motionSettings = {...motionDefaults};
+ const defaults = {font: 20, spacing: 1.9, paragraph: 0.3, theme: 'paper', auto: true};
+ let settings = {...defaults}, opened = false, lastFocus;
+ let nativeTheme = null;
+ let controlsVisible = false, savedPosition = 0, restoring = false, saveTimer;
+ const positionKey = `narouPosition:${location.pathname}`;
+ const notify = data => browser.runtime.sendNativeMessage('narou', data).catch(() => {});
+ function setControls(visible) {
+ controlsVisible = visible;
+ panel.classList.toggle('chrome-hidden', !visible);
+ header.inert = !visible;
+ resetMotion();
+ footer.inert = !visible;
+ }
+ function savePosition() {
+ if (!opened || restoring) return;
+ savedPosition = fraction();
+ browser.storage.local.set({[positionKey]: {position: savedPosition, updated: Date.now()}}).catch(() => {});
+ }
+ const inertBefore = new Map();
+ const host = document.createElement('div');
+ host.id = 'narou-tategaki-host';
+ host.style.setProperty('all', 'initial', 'important');
+ host.style.setProperty('position', 'fixed', 'important');
+ host.style.setProperty('inset', '0', 'important');
+ host.style.setProperty('z-index', '2147483647', 'important');
+ host.style.setProperty('pointer-events', 'none', 'important');
+ const shadow = host.attachShadow({mode: 'open'});
+ const style = document.createElement('style');
+ style.textContent = `
+ :host { color-scheme: light; }
+ * { box-sizing: border-box; }
+ [hidden] { display: none !important; }
+ button, select, input { font: inherit; }
+ button, select { border: 1px solid var(--border); background: var(--bg); color: var(--fg); border-radius: 7px; padding: 7px 10px; }
+ button, a, select, input { touch-action: manipulation; }
+ button, a { cursor: pointer; }
+ button:hover { background: var(--hover); }
+ :focus-visible { outline: 3px solid #459a83; outline-offset: 2px; }
+ #launch { pointer-events: auto; position: fixed; right: 20px; bottom: 20px; background: #25483f; color: #fff6df; border: 0; font: 16px sans-serif; box-shadow: 0 2px 12px #0004; padding: 12px 18px; }
+ #panel { --bg: #f6f0e3; --fg: #2d332f; --border: #cfc8b9; --hover: #e8dfce; pointer-events: auto; position: fixed; inset: 0; height: 100vh; height: 100dvh; display: grid; grid-template-rows: minmax(0, 1fr); background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
+ #panel[data-theme="white"] { --bg: #fff; --fg: #222; --border: #ddd; --hover: #eee; }
+ #panel[data-theme="dark"] { --bg: #18201e; --fg: #e2e4de; --border: #45504b; --hover: #303b35; color-scheme: dark; }
+ header, footer { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 18px; }
+ header { max-height: 70dvh; overflow-y: auto; position: absolute; top: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 4px 18px #0002; border-bottom: 1px solid var(--border); }
+ footer { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 -4px 18px #0002; border-top: 1px solid var(--border); }
+ #motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
+ header strong { margin-right: auto; }
+ label { display: inline-flex; align-items: center; gap: 6px; }
+ input[type=range] { width: 85px; accent-color: #39866e; }
+ input[type=checkbox] { accent-color: #39866e; }
+ #viewport { grid-row: 1; min-height: 0; touch-action: pan-x pinch-zoom; scrollbar-width: none; overflow-x: auto; overflow-y: hidden; direction: rtl; overscroll-behavior: contain; scrollbar-color: #849589 var(--bg); }
+ article { writing-mode: vertical-rl; text-orientation: mixed; direction: ltr; height: 100%; width: max-content; min-width: 100%; padding: 30px 40px; font: var(--font, 22px)/var(--spacing, 1.9) "Yu Mincho", "Hiragino Mincho ProN", "Noto Serif CJK JP", "Noto Serif JP", serif; overflow-wrap: break-word; line-break: strict; }
+ h1 { font-size: 1.1em; font-weight: 600; line-height: 1.8; margin: 0 0 0 1.8em; }
+ section { margin: 0 0 0 2em; }
+ section:last-child { margin: 0; }
+ h2 { font-size: .7em; font-weight: normal; opacity: .7; margin: 0 0 0 1em; }
+ p { margin: 0 0 0 var(--paragraph, .3em); padding: 0; }
+ ruby { ruby-position: over; }
+ rt { font-size: .5em; }
+ img { max-height: 100%; max-width: 70vw; object-fit: contain; }
+ article a { color: inherit; text-decoration: underline; }
+ nav { display: flex; gap: 12px; }
+ nav a { color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--border); }
+ #progress { flex: 1; min-width: 80px; direction: rtl; }
+ #percent { font-variant-numeric: tabular-nums; min-width: 4ch; }
+ .chrome-hidden header, .chrome-hidden footer { visibility: hidden; pointer-events: none; }
+ button, select { min-height: 44px; }
+ label { min-height: 36px; }
+ .hint { opacity: .65; font-size: 12px; }
+ @media(max-width: 700px) { header, footer { gap: 7px; padding: 8px; } .hint { display: none; } article { padding: 22px; } #motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
+ header strong { display: none; } }
+ @media(prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
+ `;
+ shadow.append(style);
+ const el = (tag, text, attrs = {}) => {
+ const node = document.createElement(tag);
+ if (text) node.textContent = text;
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
+ return node;
+ };
+ const launch = el('button', '縦書き', {id: 'launch', type: 'button', title: '縦書き Reader (Alt+Shift+V)'});
+ const panel = el('div', '', {id: 'panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '縦書き Reader'});
+ panel.hidden = true;
+ const header = el('header');
+ header.append(el('strong', '縦書き Reader'));
+ function slider(label, id, min, max, step) {
+ const wrap = el('label', label);
+ const input = el('input', '', {id, type: 'range', min, max, step});
+ const output = el('output');
+ wrap.append(input, output); header.append(wrap);
+ return [input, output];
+ }
+ const [font, fontOut] = slider('文字', 'font', 14, 40, 1);
+ const [spacing, spaceOut] = slider('行間', 'spacing', 1.4, 3, 0.1);
+ const [paragraph, paragraphOut] = slider('段落', 'paragraph', 0, 2, 0.1);
+ const themeLabel = el('label', '配色');
+ const theme = el('select', '', {'aria-label': '配色'});
+ for (const [value, label] of [['paper', '紙'], ['white', '白'], ['dark', '黒']]) theme.append(el('option', label, {value}));
+ themeLabel.append(theme); header.append(themeLabel);
+ const autoLabel = el('label', '自動');
+ const auto = el('input', '', {type: 'checkbox', title: 'Automatically open vertical mode on chapters'});
+ autoLabel.prepend(auto); header.append(autoLabel);
+ const appMenu = el('button', '☰', {type: 'button', 'aria-label': 'App menu'});
+ appMenu.addEventListener('click', () => notify({type: 'menu'}));
+ header.prepend(appMenu);
+ const close = el('button', '元のページ', {type: 'button'});
+ const hideControls = el('button', '読書に戻る', {type: 'button'});
+ hideControls.addEventListener('click', () => { setControls(false); viewport.focus({preventScroll: true}); });
+ header.append(close, hideControls);
+ const motionToggle = el('button', '傾き: OFF', {id: 'motion-toggle', type: 'button', 'aria-pressed': 'false'});
+ header.append(motionToggle);
+ const motionDetails = el('details', '', {id: 'motion-settings'});
+ motionDetails.append(el('summary', '傾きスクロール設定 / Tilt scrolling'));
+ const motionStatus = el('p', 'Hold the phone comfortably, enable tilt, then set neutral.');
+ const neutralButton = el('button', 'この傾きを基準に / Set neutral', {type: 'button', id: 'motion-neutral'});
+ neutralButton.disabled = true;
+ motionDetails.append(motionStatus, neutralButton);
+ const motionInputs = {};
+ for (const [key, label, min, max, step, suffix] of [
+ ['acceleration', '加速 / Acceleration', 100, 3000, 100, ' px/s²'],
+ ['speed', '最高速度 / Max speed', 20, 600, 10, ' px/s'],
+ ['deadzone', '停止範囲 / Neutral zone ±', .02, .4, .01, ' g'],
+ ['brake', 'ブレーキ / Braking', .01, 1, .01, '']
+ ]) {
+ const labelNode = el('label', label);
+ const input = el('input', '', {id: `motion-${key}`, type: 'range', min, max, step});
+ const output = el('output');
+ input.value = motionSettings[key]; output.textContent = input.value + suffix;
+ input.addEventListener('input', () => {
+ motionSettings[key] = Number(input.value); output.textContent = input.value + suffix;
+ resetMotion(); saveMotion();
+ });
+ motionInputs[key] = {input, output, suffix, min, max};
+ labelNode.append(input, output); motionDetails.append(labelNode);
+ }
+ const reverseLabel = el('label', '方向を逆転 / Reverse direction');
+ const reverseMotion = el('input', '', {id: 'motion-reverse', type: 'checkbox'});
+ reverseLabel.prepend(reverseMotion); motionDetails.append(reverseLabel);
+ reverseMotion.addEventListener('change', () => { motionSettings.reverse = reverseMotion.checked; resetMotion(); saveMotion(); });
+ motionDetails.append(el('p', 'Tilt sideways to accelerate. Return to neutral to brake. Touch the text to stop. Settings stay paused; return to reading to scroll.'));
+ header.append(motionDetails);
+ const viewport = el('div', '', {id: 'viewport', tabindex: '0', role: 'region', 'aria-label': '本文。左矢印またはスペースで読み進めます。'});
+ const article = el('article', '', {lang: 'ja'});
+ const title = document.querySelector('.p-novel__title, .novel_subtitle');
+ article.append(el('h1', title ? title.textContent.trim() : document.title));
+
+ // Copy only passive reading markup. No scripts, page styles, IDs or event handlers.
+ const allowed = new Set(['P', 'DIV', 'BR', 'RUBY', 'RB', 'RT', 'RP', 'RTC', 'SPAN', 'EM', 'STRONG', 'B', 'I', 'A', 'IMG', 'SUP', 'SUB']);
+ const discard = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON']);
+ function safeURL(raw) {
+ if (!raw) return null;
+ try { const u = new URL(raw, document.baseURI); return ['http:', 'https:'].includes(u.protocol) ? u.href : null; }
+ catch (_) { return null; }
+ }
+ function copy(node) {
+ if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.textContent);
+ if (node.nodeType !== Node.ELEMENT_NODE || discard.has(node.tagName)) return document.createDocumentFragment();
+ // Blank spacer paragraphs add entire empty columns in vertical layout.
+ if (node.tagName === 'P' && !node.textContent.trim() &&
+ [...node.querySelectorAll('*')].every(child => ['BR', 'SPAN', 'WBR'].includes(child.tagName))) {
+ return document.createDocumentFragment();
+ }
+ const clean = allowed.has(node.tagName) ? el(node.tagName.toLowerCase()) : document.createDocumentFragment();
+ if (node.tagName === 'A') {
+ const url = safeURL(node.getAttribute('href'));
+ if (url) clean.setAttribute('href', url);
+ }
+ if (node.tagName === 'IMG') {
+ const url = safeURL(node.getAttribute('src'));
+ if (url) clean.setAttribute('src', url);
+ clean.setAttribute('alt', node.getAttribute('alt') || '');
+ }
+ for (const child of node.childNodes) clean.append(copy(child));
+ return clean;
+ }
+ for (const source of sources) {
+ const section = el('section');
+ if (source.matches('.p-novel__text--preface, #novel_p')) section.append(el('h2', '前書き'));
+ if (source.matches('.p-novel__text--afterword, #novel_a')) section.append(el('h2', '後書き'));
+ for (const child of source.childNodes) section.append(copy(child));
+ article.append(section);
+ }
+ viewport.append(article);
+ const footer = el('footer');
+ const nav = el('nav', '', {'aria-label': '章移動'});
+ const pager = document.querySelector('.c-pager, .novel_bn');
+ if (pager) for (const link of pager.querySelectorAll('a[href]')) {
+ const url = safeURL(link.getAttribute('href'));
+ if (url) nav.append(el('a', link.textContent.trim(), {href: url}));
+ }
+ footer.append(nav);
+ const back = el('button', '→', {type: 'button', 'aria-label': '前の列'});
+ const forward = el('button', '←', {type: 'button', 'aria-label': '次の列'});
+ footer.append(back, forward);
+ const progress = el('input', '', {id: 'progress', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': '読書位置'});
+ const percent = el('span', '0%', {id: 'percent'});
+ footer.append(progress, percent, el('span', 'タップで操作を表示 · 右へスワイプで読み進める', {class: 'hint'}));
+ panel.append(header, viewport, footer); shadow.append(launch, panel);
+ document.body.append(host);
+ const lock = el('style');
+ lock.textContent = 'html.narou-tategaki-open { overflow: hidden !important; }';
+ document.head.append(lock);
+
+ const motion = new NarouMotion();
+ let motionEnabled = false, motionSample = null, sampleAt = 0, lastFrame = 0;
+ let neutral = null, motionRotation = null, touching = false, motionTimer, motionFrame;
+ let motionGeneration = 0, polling = false;
+ function resetMotion() { motion.reset(); lastFrame = 0; }
+ function saveMotion() { browser.storage.local.set({narouMotion: {...motionSettings, enabled: motionEnabled}}).catch(() => {}); }
+ function updateMotionButton() {
+ motionToggle.textContent = motionEnabled ? '傾き: ON' : '傾き: OFF';
+ motionToggle.setAttribute('aria-pressed', String(motionEnabled));
+ }
+ function animateMotion(now) {
+ motionFrame = null;
+ if (!motionEnabled || !opened || document.hidden) { resetMotion(); return; }
+ const paused = controlsVisible || touching || restoring || !motionSample?.active ||
+ now - sampleAt > 350 || neutral === null || String(window.getSelection()).trim();
+ if (paused) resetMotion();
+ else {
+ if (lastFrame) {
+ const delta = motion.step(motionSample.x, (now - lastFrame) / 1000, {...motionSettings, neutral});
+ move(delta);
+ if ((delta > 0 && viewport.scrollLeft <= -range()) || (delta < 0 && viewport.scrollLeft >= 0)) motion.reset();
+ }
+ lastFrame = now;
+ }
+ motionFrame = requestAnimationFrame(animateMotion);
+ }
+ async function pollMotion() {
+ if (polling || !motionEnabled || !opened || document.hidden) return;
+ polling = true;
+ const generation = motionGeneration;
+ try {
+ const sample = await browser.runtime.sendNativeMessage('narou', {
+ type: 'motion', enabled: true, moving: Math.abs(motion.velocity) > .1
+ });
+ if (generation !== motionGeneration) return;
+ if (!sample?.available) {
+ motionEnabled = false; updateMotionButton();
+ motionStatus.textContent = 'Accelerometer unavailable on this device.';
+ stopMotion(); return;
+ }
+ motionSample = sample; sampleAt = performance.now();
+ neutralButton.disabled = !sample.active;
+ if (!sample.active || !Number.isFinite(sample.x)) {
+ motionSample = null; resetMotion(); motionStatus.textContent = 'Paused — waiting for sensor.';
+ } else {
+ if (motionRotation !== sample.rotation) {
+ motionRotation = sample.rotation; neutral = sample.x; resetMotion();
+ }
+ motionStatus.textContent = `Tilt ${sample.x.toFixed(2)} g · Neutral ${neutral.toFixed(2)} g${controlsVisible ? ' · Paused' : ''}`;
+ }
+ } catch (error) {
+ if (generation !== motionGeneration) return;
+
+ motionSample = null;
+ resetMotion();
+ neutralButton.disabled = true;
+
+ const detail = String(
+ error?.message || error || 'Unknown error'
+ ).slice(0, 240);
+
+ motionStatus.textContent = `Sensor connection unavailable: ${detail}`;
+ } finally {
+ polling = false;
+ if (motionEnabled && opened && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
+ }
+ }
+ function startMotion() {
+ if (!motionEnabled || !opened || document.hidden) return;
+ if (!motionFrame) motionFrame = requestAnimationFrame(animateMotion);
+ clearTimeout(motionTimer); pollMotion();
+ }
+ function stopMotion() {
+ motionGeneration++; clearTimeout(motionTimer); cancelAnimationFrame(motionFrame); motionFrame = null;
+ motionSample = null; neutralButton.disabled = true; resetMotion();
+ notify({type: 'motion', enabled: false});
+ }
+ motionToggle.addEventListener('click', () => {
+ motionEnabled = !motionEnabled; updateMotionButton(); saveMotion();
+ if (motionEnabled) { neutral = null; motionRotation = null; motionDetails.open = true; startMotion(); }
+ else { stopMotion(); motionStatus.textContent = 'Tilt scrolling off.'; }
+ });
+ neutralButton.addEventListener('click', () => {
+ if (!motionSample?.active || performance.now() - sampleAt > 350) return;
+ neutral = motionSample.x; resetMotion();
+ motionStatus.textContent = `Neutral set: ${neutral.toFixed(2)} g. Return to reading to start.`;
+ });
+ viewport.addEventListener('pointerdown', () => { touching = true; resetMotion(); }, {passive: true});
+ window.addEventListener('pointerup', () => { touching = false; }, {passive: true});
+ window.addEventListener('pointercancel', () => { touching = false; }, {passive: true});
+ document.addEventListener('visibilitychange', () => {
+ touching = false;
+ if (document.hidden) stopMotion(); else startMotion();
+ });
+ window.addEventListener('pagehide', stopMotion);
+ window.addEventListener('pageshow', () => {
+ if (opened) { notify({type: 'reader', open: true}); notify({type: 'theme', theme: settings.theme}); }
+ startMotion();
+ });
+ // Continuous scrolling must checkpoint periodically, not only after scrolling stops.
+ setInterval(() => { if (opened && motionEnabled && Math.abs(motion.velocity) > .1) savePosition(); }, 2000);
+ browser.storage.local.get('narouMotion').then(result => {
+ const stored = result.narouMotion || {};
+ for (const [key, control] of Object.entries(motionInputs)) {
+ if (Number.isFinite(stored[key])) motionSettings[key] = Math.min(control.max, Math.max(control.min, stored[key]));
+ control.input.value = motionSettings[key]; control.output.textContent = control.input.value + control.suffix;
+ }
+ motionSettings.reverse = stored.reverse === true; reverseMotion.checked = motionSettings.reverse;
+ motionEnabled = stored.enabled === true; updateMotionButton(); startMotion();
+ }).catch(() => {});
+
+ const range = () => Math.max(0, viewport.scrollWidth - viewport.clientWidth);
+ const fraction = () => range() ? Math.min(1, Math.max(0, -viewport.scrollLeft / range())) : 0;
+ function updateProgress() {
+ progress.value = String(Math.round(fraction() * 1000));
+ percent.textContent = `${Math.round(fraction() * 100)}%`;
+ }
+ function apply(position = opened ? fraction() : savedPosition) {
+ restoring = true;
+ panel.dataset.theme = settings.theme;
+ if (nativeTheme !== settings.theme) {
+ nativeTheme = settings.theme;
+ notify({type: 'theme', theme: settings.theme});
+ }
+ article.style.setProperty('--font', `${settings.font}px`);
+ article.style.setProperty('--spacing', settings.spacing);
+ article.style.setProperty('--paragraph', `${settings.paragraph}em`);
+ font.value = settings.font; spacing.value = settings.spacing; paragraph.value = settings.paragraph;
+ fontOut.textContent = `${settings.font}px`; spaceOut.textContent = `${settings.spacing.toFixed(1)}`;
+ paragraphOut.textContent = `${settings.paragraph.toFixed(1)}em`;
+ theme.value = settings.theme; auto.checked = settings.auto;
+ requestAnimationFrame(() => {
+ if (opened) viewport.scrollLeft = -position * range();
+ savedPosition = position;
+ updateProgress();
+ requestAnimationFrame(() => { restoring = false; });
+ });
+ }
+ async function save() {
+ try { await browser.storage.local.set({narouTategaki: settings}); }
+ catch (err) { console.warn('縦書き: settings could not be saved', err); }
+ }
+ function open() {
+ if (opened) return;
+ opened = true; lastFocus = document.activeElement;
+ for (const child of document.body.children) if (child !== host) { inertBefore.set(child, child.inert); child.inert = true; }
+ document.documentElement.classList.add('narou-tategaki-open');
+ launch.hidden = true; panel.hidden = false;
+ setControls(false); apply(savedPosition); viewport.focus({preventScroll: true});
+ notify({type: 'reader', open: true});
+ startMotion();
+ }
+ function shut() {
+ if (!opened) return;
+ savePosition(); stopMotion(); notify({type: 'reader', open: false});
+ opened = false; panel.hidden = true; launch.hidden = false;
+ document.documentElement.classList.remove('narou-tategaki-open');
+ for (const [node, inert] of inertBefore) node.inert = inert;
+ inertBefore.clear();
+ if (lastFocus && lastFocus !== document.body && lastFocus.isConnected) lastFocus.focus({preventScroll: true});
+ else launch.focus({preventScroll: true});
+ }
+ const toggle = () => opened ? shut() : open();
+ launch.addEventListener('click', open); close.addEventListener('click', shut);
+ function move(amount) { viewport.scrollLeft = Math.max(-range(), Math.min(0, viewport.scrollLeft - amount)); }
+ const page = () => viewport.clientWidth * .85;
+ back.addEventListener('click', () => move(-page()));
+ forward.addEventListener('click', () => move(page()));
+ viewport.addEventListener('wheel', event => {
+ if (event.ctrlKey || event.shiftKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
+ const scale = event.deltaMode === 1 ? settings.font : event.deltaMode === 2 ? page() : 1;
+ move(event.deltaY * scale); event.preventDefault();
+ }, {passive: false});
+ viewport.addEventListener('scroll', () => {
+ updateProgress();
+ if (opened && !restoring) {
+ savedPosition = fraction();
+ clearTimeout(saveTimer);
+ saveTimer = setTimeout(savePosition, 250);
+ }
+ }, {passive: true});
+ // Ignore swipes, links and long presses; preserve native selection and touch scrolling.
+ let pointerStart = null;
+ viewport.addEventListener('pointerdown', event => {
+ pointerStart = {x: event.clientX, y: event.clientY, time: performance.now()};
+ }, {passive: true});
+ viewport.addEventListener('pointercancel', () => { pointerStart = null; });
+ viewport.addEventListener('pointerup', event => {
+ const start = pointerStart; pointerStart = null;
+ if (!start || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10 ||
+ performance.now() - start.time > 450 || event.target.closest('a') ||
+ String(window.getSelection()).trim()) return;
+ setControls(!controlsVisible);
+ }, {passive: true});
+ document.addEventListener('visibilitychange', () => { if (document.hidden) savePosition(); });
+ window.addEventListener('pagehide', savePosition);
+ progress.addEventListener('input', () => { viewport.scrollLeft = -Number(progress.value) / 1000 * range(); updateProgress(); });
+ panel.addEventListener('keydown', event => {
+ if (event.key === 'Escape') { event.preventDefault(); shut(); return; }
+ // Native controls and chapter links keep their normal keyboard behavior.
+ if (event.target !== viewport && event.target !== panel) return;
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
+ const actions = {ArrowLeft: () => move(settings.font * settings.spacing * 3), ArrowRight: () => move(-settings.font * settings.spacing * 3), PageDown: () => move(page()), PageUp: () => move(-page()), ' ': () => move(event.shiftKey ? -page() : page()), Home: () => { viewport.scrollLeft = 0; }, End: () => { viewport.scrollLeft = -range(); }};
+ if (actions[event.key]) { event.preventDefault(); actions[event.key](); updateProgress(); }
+ });
+ font.addEventListener('input', () => { settings.font = Number(font.value); apply(); save(); });
+ spacing.addEventListener('input', () => { settings.spacing = Number(spacing.value); apply(); save(); });
+ paragraph.addEventListener('input', () => { settings.paragraph = Number(paragraph.value); apply(); save(); });
+ theme.addEventListener('change', () => { settings.theme = theme.value; apply(); save(); });
+ auto.addEventListener('change', () => { settings.auto = auto.checked; save(); });
+ let lastWidth = 0, lastHeight = 0;
+ new ResizeObserver(() => {
+ if (!opened) return;
+ const width = viewport.clientWidth, height = viewport.clientHeight;
+ if (width !== lastWidth || height !== lastHeight) {
+ lastWidth = width; lastHeight = height;
+ apply(savedPosition);
+ } else updateProgress();
+ }).observe(viewport);
+ browser.runtime.onMessage.addListener(message => { if (message.type === 'narou-tategaki-toggle') toggle(); });
+ browser.storage.local.get(['narouTategaki', positionKey]).then(result => {
+ const position = result[positionKey]?.position;
+ savedPosition = Number.isFinite(position) ? Math.min(1, Math.max(0, position)) : 0;
+ const stored = result.narouTategaki || {};
+ settings.font = Number.isFinite(stored.font) ? Math.min(40, Math.max(14, stored.font)) : defaults.font;
+ settings.spacing = Number.isFinite(stored.spacing) ? Math.min(3, Math.max(1.4, stored.spacing)) : defaults.spacing;
+ settings.paragraph = Number.isFinite(stored.paragraph) ? Math.min(2, Math.max(0, stored.paragraph)) : defaults.paragraph;
+ settings.theme = ['paper', 'white', 'dark'].includes(stored.theme) ? stored.theme : defaults.theme;
+ settings.auto = typeof stored.auto === 'boolean' ? stored.auto : defaults.auto;
+ if (settings.auto && !opened) open(); else apply(savedPosition);
+ }).catch(() => { open(); });
+ // Keep a bounded local history; no network requests or reading telemetry.
+ browser.storage.local.get(null).then(all => {
+ const old = Object.keys(all).filter(key => key.startsWith('narouPosition:'))
+ .sort((a, b) => (all[b]?.updated || 0) - (all[a]?.updated || 0)).slice(200);
+ if (old.length) return browser.storage.local.remove(old);
+ }).catch(() => {});
+})();
diff --git a/app/src/main/assets/reader/manifest.json b/app/src/main/assets/reader/manifest.json
new file mode 100644
index 0000000..8305f77
--- /dev/null
+++ b/app/src/main/assets/reader/manifest.json
@@ -0,0 +1,34 @@
+{
+ "manifest_version": 2,
+ "name": "縦書き Reader — Android",
+ "version": "1.1.0",
+ "description": "Built-in vertical reading view for Narou Reader.",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "narou-reader-android@penguin.local",
+ "data_collection_permissions": {
+ "required": [
+ "none"
+ ]
+ }
+ }
+ },
+ "permissions": [
+ "storage",
+ "geckoViewAddons",
+ "nativeMessaging",
+ "nativeMessagingFromContent"
+ ],
+ "content_scripts": [
+ {
+ "matches": [
+ "https://ncode.syosetu.com/*"
+ ],
+ "js": [
+ "motion.js",
+ "content.js"
+ ],
+ "run_at": "document_idle"
+ }
+ ]
+}
diff --git a/app/src/main/assets/reader/motion.js b/app/src/main/assets/reader/motion.js
new file mode 100644
index 0000000..bb3d53a
--- /dev/null
+++ b/app/src/main/assets/reader/motion.js
@@ -0,0 +1,31 @@
+/* Port of the user's AccelerScroll / ReaderContent model: acceleration, speed cap,
+ * neutral-zone exponential braking. Units are CSS px/s and CSS px/s².
+ * No sensor integration or DOM dependencies, so the physics can be tested directly. */
+(function (root) {
+ 'use strict';
+ class Motion {
+ constructor() { this.reset(); }
+ reset() { this.velocity = 0; }
+ step(x, seconds, config) {
+ if (!Number.isFinite(x) || !Number.isFinite(seconds) || seconds <= 0 || seconds > .15) {
+ this.reset(); return 0;
+ }
+ const delta = x - config.neutral;
+ const acceleration = Math.abs(delta) <= config.deadzone ? 0 : delta * (config.reverse ? -1 : 1);
+ // Subdivide to keep acceleration/braking consistent at 60/90/120 Hz.
+ let distance = 0;
+ while (seconds > .000001) {
+ const dt = Math.min(seconds, .005), previous = this.velocity;
+ if (acceleration === 0) this.velocity *= Math.pow(1 - config.brake, dt / .02);
+ else this.velocity = Math.max(-config.speed, Math.min(config.speed,
+ this.velocity + config.acceleration * acceleration * dt));
+ if (Math.abs(this.velocity) < .01) this.velocity = 0;
+ distance += (previous + this.velocity) * .5 * dt;
+ seconds -= dt;
+ }
+ return distance;
+ }
+ }
+ if (typeof module === 'object' && module.exports) module.exports = Motion;
+ else root.NarouMotion = Motion;
+})(globalThis);
diff --git a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
new file mode 100644
index 0000000..ac49b19
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
@@ -0,0 +1,351 @@
+package net.astropenguin.naroureader;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.InputType;
+import android.view.Gravity;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowInsetsController;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ProgressBar;
+import android.widget.Toast;
+import org.json.JSONObject;
+import org.mozilla.geckoview.AllowOrDeny;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoView;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebRequestError;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class MainActivity extends Activity {
+ private static final String DEFAULT_URL = "https://ncode.syosetu.com/n0552ep/75/";
+ private static final Pattern SHARED_URL = Pattern.compile("https://ncode\\.syosetu\\.com/[^\\s<>\\\"]*");
+ private GeckoSession session;
+ private GeckoView geckoView;
+ private FrameLayout root;
+ private boolean darkReaderTheme;
+ private Button menuButton;
+ private ProgressBar progress;
+ private SharedPreferences prefs;
+ private boolean canGoBack, ready, destroyed, reading;
+ private String pendingUrl, currentUrl;
+ private AlertDialog menuDialog;
+ private TiltSensor tilt;
+ private boolean resumed;
+
+ @Override public void onCreate(Bundle state) {
+ super.onCreate(state);
+ tilt = new TiltSensor(this);
+ prefs = getSharedPreferences("reader", MODE_PRIVATE);
+ currentUrl = prefs.getString("lastUrl", DEFAULT_URL);
+ pendingUrl = intentUrl(getIntent());
+ if (pendingUrl == null) pendingUrl = currentUrl;
+ if (!isInternal(pendingUrl)) pendingUrl = DEFAULT_URL;
+ getWindow().setStatusBarColor(Color.TRANSPARENT);
+ getWindow().setNavigationBarColor(Color.TRANSPARENT);
+ if (Build.VERSION.SDK_INT >= 28) {
+ WindowManager.LayoutParams attr = getWindow().getAttributes();
+ attr.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ getWindow().setAttributes(attr);
+ }
+ root = new FrameLayout(this);
+ geckoView = new GeckoView(this);
+ root.addView(geckoView, new FrameLayout.LayoutParams(-1, -1));
+ progress = new ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal);
+ progress.setMax(100);
+ root.addView(progress, new FrameLayout.LayoutParams(-1, dp(3), Gravity.TOP));
+ menuButton = new Button(this);
+ menuButton.setText("☰");
+ menuButton.setTextSize(22);
+ menuButton.setTextColor(Color.WHITE);
+ menuButton.setContentDescription("Reader menu");
+ GradientDrawable bg = new GradientDrawable();
+ bg.setColor(Color.rgb(37, 72, 63)); bg.setCornerRadius(dp(28));
+ menuButton.setBackground(bg); menuButton.setElevation(dp(5));
+ FrameLayout.LayoutParams menuLayout = new FrameLayout.LayoutParams(dp(52), dp(52), Gravity.BOTTOM | Gravity.END);
+ menuLayout.setMargins(dp(16), dp(16), dp(16), dp(16));
+ root.addView(menuButton, menuLayout);
+ menuButton.setOnClickListener(v -> showMenu());
+ if (Build.VERSION.SDK_INT >= 28) root.setOnApplyWindowInsetsListener((v, insets) -> {
+ android.view.DisplayCutout cutout = insets.getDisplayCutout();
+ if (cutout != null) root.setPadding(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(), cutout.getSafeInsetRight(), cutout.getSafeInsetBottom());
+ else root.setPadding(0, 0, 0, 0);
+ return insets;
+ });
+ setContentView(root);
+ applyReaderTheme(prefs.getString("readerTheme", "paper"));
+ if (Build.VERSION.SDK_INT >= 33) getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
+ android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::navigateBack);
+ immersive();
+ ReaderApplication app = (ReaderApplication) getApplication();
+ session = new GeckoSession();
+ session.setContentDelegate(new GeckoSession.ContentDelegate() {
+ @Override public void onCrash(GeckoSession s) {
+ reading = false; tilt.stop(); menuButton.setVisibility(View.VISIBLE);
+ new AlertDialog.Builder(MainActivity.this).setTitle("Reader stopped")
+ .setMessage("The web engine stopped. Reopen this chapter to continue.")
+ .setPositiveButton("Reopen", (d, w) -> recreate()).setNegativeButton("Close", (d, w) -> finish()).show();
+ }
+ });
+ session.setPromptDelegate(new GeckoSession.PromptDelegate() {
+ @Override public GeckoResult<PromptResponse> onChoicePrompt(GeckoSession s, ChoicePrompt prompt) {
+ // The reader uses a single-choice HTML select for its theme.
+ if (prompt.type == ChoicePrompt.Type.MULTIPLE) return GeckoResult.fromValue(prompt.dismiss());
+ java.util.ArrayList<ChoicePrompt.Choice> choices = new java.util.ArrayList<>();
+ flattenChoices(prompt.choices, choices);
+ String[] labels = new String[choices.size()];
+ int selected = -1;
+ for (int i = 0; i < choices.size(); i++) {
+ labels[i] = choices.get(i).label;
+ if (choices.get(i).selected) selected = i;
+ }
+ GeckoResult<PromptResponse> result = new GeckoResult<>();
+ AlertDialog dialog = new AlertDialog.Builder(MainActivity.this)
+ .setTitle(prompt.title == null ? "Choose" : prompt.title)
+ .setSingleChoiceItems(labels, selected, (d, which) -> {
+ if (!prompt.isComplete()) result.complete(prompt.confirm(choices.get(which)));
+ d.dismiss();
+ }).setNegativeButton("Cancel", (d, which) -> {
+ if (!prompt.isComplete()) result.complete(prompt.dismiss());
+ }).create();
+ dialog.setOnCancelListener(d -> { if (!prompt.isComplete()) result.complete(prompt.dismiss()); });
+ prompt.setDelegate(new PromptInstanceDelegate() {
+ @Override public void onPromptDismiss(BasePrompt p) { dialog.dismiss(); }
+ });
+ dialog.setOnDismissListener(d -> immersive());
+ dialog.show();
+ return result;
+ }
+ });
+ session.setNavigationDelegate(new GeckoSession.NavigationDelegate() {
+ @Override public void onCanGoBack(GeckoSession s, boolean value) { canGoBack = value; }
+ @Override public void onLocationChange(GeckoSession s, String url,
+ List<GeckoSession.PermissionDelegate.ContentPermission> permissions, Boolean gesture) {
+ if (url != null && isInternal(url)) {
+ currentUrl = url;
+ // Keep resume pointed at a novel, not an incidental login or help page.
+ if ("ncode.syosetu.com".equals(Uri.parse(url).getHost())) prefs.edit().putString("lastUrl", url).apply();
+ }
+ }
+ @Override public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession s, LoadRequest request) {
+ if ("about:license".equals(request.uri)) return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ if (isInternal(request.uri)) {
+ if (request.target == TARGET_WINDOW_NEW) {
+ geckoView.post(() -> session.loadUri(request.uri));
+ return GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+ if (request.hasUserGesture && isWeb(request.uri)) openExternal(request.uri);
+ return GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+ @Override public GeckoResult<String> onLoadError(GeckoSession s, String uri, WebRequestError error) {
+ geckoView.post(() -> {
+ progress.setVisibility(View.GONE);
+ menuButton.setVisibility(View.VISIBLE);
+ Toast.makeText(MainActivity.this, "Could not load the page. Check your connection, then use Menu → Reload.", Toast.LENGTH_LONG).show();
+ });
+ return null; // Gecko's error page preserves certificate and network error details.
+ }
+ });
+ session.setProgressDelegate(new GeckoSession.ProgressDelegate() {
+ @Override public void onPageStart(GeckoSession s, String url) {
+ reading = false; tilt.stop(); menuButton.setVisibility(View.VISIBLE); progress.setProgress(0); progress.setVisibility(View.VISIBLE);
+ }
+ @Override public void onProgressChange(GeckoSession s, int value) { progress.setProgress(value); }
+ @Override public void onPageStop(GeckoSession s, boolean success) { progress.setVisibility(View.GONE); }
+ });
+ session.open(app.runtime());
+ geckoView.setSession(session);
+ app.extension().accept(extension -> {
+ if (destroyed) return;
+ session.getWebExtensionController().setMessageDelegate(extension, new WebExtension.MessageDelegate() {
+ @Override public GeckoResult<Object> onMessage(String name, Object message, WebExtension.MessageSender sender) {
+ if (sender.session != session || sender.url == null || !sender.isTopLevel() || !"https".equals(Uri.parse(sender.url).getScheme()) ||
+ !"ncode.syosetu.com".equals(Uri.parse(sender.url).getHost()) || !(message instanceof JSONObject)) return null;
+ JSONObject data = (JSONObject) message;
+ if ("motion".equals(data.optString("type"))) {
+ return GeckoResult.fromValue(tilt.sample(
+ data.optBoolean("enabled") && reading && resumed && hasWindowFocus(),
+ data.optBoolean("moving")));
+ }
+ if ("menu".equals(data.optString("type"))) showMenu();
+ if ("theme".equals(data.optString("type"))) applyReaderTheme(data.optString("theme", "paper"));
+ if ("reader".equals(data.optString("type"))) {
+ reading = data.optBoolean("open");
+ if (!reading) tilt.stop();
+ menuButton.setVisibility(reading ? View.GONE : View.VISIBLE);
+ if (reading && !prefs.getBoolean("hintShown", false)) {
+ prefs.edit().putBoolean("hintShown", true).apply();
+ Toast.makeText(MainActivity.this, "Tap the text for controls. Swipe right to read forward.", Toast.LENGTH_LONG).show();
+ }
+ }
+ return GeckoResult.fromValue(null);
+ }
+ }, "narou");
+ ready = true;
+ session.loadUri(pendingUrl);
+ }, error -> {
+ if (destroyed) return;
+ progress.setVisibility(View.GONE);
+ new AlertDialog.Builder(this).setTitle("Reader could not start")
+ .setMessage("The built-in extension could not be loaded. Restart the app.\n\n" + error.getMessage())
+ .setPositiveButton("Close", (d, w) -> finish()).show();
+ });
+ }
+
+ private static void flattenChoices(GeckoSession.PromptDelegate.ChoicePrompt.Choice[] input,
+ java.util.List<GeckoSession.PromptDelegate.ChoicePrompt.Choice> output) {
+ for (GeckoSession.PromptDelegate.ChoicePrompt.Choice choice : input) {
+ if (choice.disabled || choice.separator) continue;
+ if (choice.items != null) flattenChoices(choice.items, output);
+ else output.add(choice);
+ }
+ }
+
+ private void showMenu() {
+ if (destroyed || (menuDialog != null && menuDialog.isShowing())) return;
+ String[] actions = {"Open novel URL…", "Reload", "Share chapter", "Open in browser", "About / help"};
+ menuDialog = new AlertDialog.Builder(this).setTitle("縦書き Reader")
+ .setItems(actions, (dialog, which) -> {
+ if (which == 0) openUrlDialog();
+ if (which == 1 && ready) session.reload();
+ if (which == 2) {
+ Intent share = new Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, currentUrl);
+ startActivity(Intent.createChooser(share, "Share chapter"));
+ }
+ if (which == 3) openExternal(currentUrl);
+ if (which == 4) new AlertDialog.Builder(this).setTitle("縦書き Reader " + BuildConfig.VERSION_NAME)
+ .setMessage("Swipe right to read forward. Tap text to show or hide controls.\n\n文字: font size\n行間: column spacing\n段落: paragraph spacing\n配色: theme\n傾き: accelerometer scrolling; enable, set neutral, then return to reading\n自動: open reader automatically\n\nEmpty spacer paragraphs are removed. The last chapter and reading position are saved on this device.\n\nPowered by Mozilla GeckoView 158. No analytics. Website content and cookies stay in this app’s separate browser profile. GeckoView: MPL 2.0. Engine license details are in the supplied source package.")
+ .setNeutralButton("Engine licenses", (d, w) -> session.loadUri("about:license"))
+ .setPositiveButton("OK", null).show();
+ }).setNegativeButton("Continue reading", null).create();
+ menuDialog.setOnDismissListener(d -> immersive());
+ menuDialog.show();
+ }
+
+ private void openUrlDialog() {
+ EditText field = new EditText(this);
+ field.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
+ field.setSingleLine(true); field.setText(currentUrl); field.selectAll();
+ int pad = dp(20);
+ FrameLayout wrap = new FrameLayout(this); wrap.setPadding(pad, dp(8), pad, 0);
+ wrap.addView(field, new FrameLayout.LayoutParams(-1, -2));
+ AlertDialog dialog = new AlertDialog.Builder(this).setTitle("Open a Narou novel")
+ .setView(wrap).setPositiveButton("Open", null).setNegativeButton("Cancel", null).create();
+ dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
+ String url = field.getText().toString().trim();
+ if (url.startsWith("ncode.syosetu.com/")) url = "https://" + url;
+ if (!isInternal(url) || !"ncode.syosetu.com".equals(Uri.parse(url).getHost())) {
+ field.setError("Use an https://ncode.syosetu.com/ URL"); return;
+ }
+ pendingUrl = url;
+ if (ready) session.loadUri(url);
+ dialog.dismiss();
+ }));
+ dialog.setOnDismissListener(d -> immersive());
+ dialog.show();
+ }
+
+ private static boolean isWeb(String url) {
+ if (url == null) return false;
+ Uri uri = Uri.parse(url);
+ return "https".equals(uri.getScheme()) && uri.getHost() != null && uri.getUserInfo() == null;
+ }
+ private static boolean isInternal(String url) {
+ if (!isWeb(url)) return false;
+ String host = Uri.parse(url).getHost();
+ return "syosetu.com".equals(host) || host.endsWith(".syosetu.com");
+ }
+ private String intentUrl(Intent intent) {
+ if (Intent.ACTION_VIEW.equals(intent.getAction())) {
+ String url = intent.getDataString();
+ return isInternal(url) ? url : null;
+ }
+ if (Intent.ACTION_SEND.equals(intent.getAction())) {
+ String shared = intent.getStringExtra(Intent.EXTRA_TEXT);
+ if (shared != null) {
+ Matcher match = SHARED_URL.matcher(shared);
+ if (match.find()) return match.group();
+ }
+ }
+ return null;
+ }
+ private void openExternal(String url) {
+ if (!isWeb(url)) return;
+ // A browser selector avoids routing our own ncode URL intent back into this app.
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ intent.setSelector(new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_BROWSER));
+ try { startActivity(intent); }
+ catch (ActivityNotFoundException e) { Toast.makeText(this, "No browser is available.", Toast.LENGTH_SHORT).show(); }
+ }
+ @Override protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent); setIntent(intent);
+ String url = intentUrl(intent);
+ if (url != null) { pendingUrl = url; if (ready) session.loadUri(url); }
+ }
+ private void navigateBack() {
+ if (session != null && canGoBack) session.goBack();
+ else finish();
+ }
+ @Override public void onBackPressed() { navigateBack(); }
+ @Override protected void onResume() { super.onResume(); resumed = true; if (session != null) session.setActive(true); immersive(); }
+ @Override protected void onPause() { resumed = false; tilt.stop(); if (session != null) session.setActive(false); super.onPause(); }
+ @Override public void onWindowFocusChanged(boolean focused) { super.onWindowFocusChanged(focused); if (focused) immersive(); else if (tilt != null) tilt.stop(); }
+ @Override protected void onDestroy() {
+ destroyed = true;
+ if (tilt != null) tilt.stop();
+ if (geckoView != null) geckoView.releaseSession();
+ if (session != null) session.close();
+ super.onDestroy();
+ }
+ /** Match the native cutout/safe-area background to the extension's palette. */
+ private void applyReaderTheme(String theme) {
+ if (!"dark".equals(theme) && !"white".equals(theme)) theme = "paper";
+ darkReaderTheme = "dark".equals(theme);
+ int background = darkReaderTheme ? Color.rgb(24, 32, 30)
+ : "white".equals(theme) ? Color.WHITE : Color.rgb(246, 240, 227);
+ root.setBackgroundColor(background);
+ getWindow().setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(background));
+ if (!theme.equals(prefs.getString("readerTheme", "paper"))) {
+ prefs.edit().putString("readerTheme", theme).apply();
+ }
+ immersive();
+ }
+
+ private int dp(int value) { return Math.round(value * getResources().getDisplayMetrics().density); }
+ @SuppressWarnings("deprecation") private void immersive() {
+ if (Build.VERSION.SDK_INT >= 30) {
+ getWindow().setDecorFitsSystemWindows(false);
+ WindowInsetsController controller = getWindow().getInsetsController();
+ if (controller != null) {
+ int lightBars = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
+ | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
+ controller.setSystemBarsAppearance(darkReaderTheme ? 0 : lightBars, lightBars);
+ controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ controller.hide(WindowInsets.Type.systemBars());
+ }
+ } else {
+ getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+ View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
+ (darkReaderTheme ? 0 : View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR));
+ }
+ }
+}
diff --git a/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java b/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java
new file mode 100644
index 0000000..7498694
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/naroureader/ReaderApplication.java
@@ -0,0 +1,25 @@
+package net.astropenguin.naroureader;
+
+import android.app.Application;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.WebExtension;
+
+/** Gecko supports one runtime per app process. Keep it across Activity lifetimes. */
+public final class ReaderApplication extends Application {
+ private GeckoRuntime runtime;
+ private GeckoResult<WebExtension> extension;
+
+ public synchronized GeckoRuntime runtime() {
+ if (runtime == null) {
+ runtime = GeckoRuntime.create(this, new GeckoRuntimeSettings.Builder()
+ .consoleOutput(BuildConfig.DEBUG).build());
+ extension = runtime.getWebExtensionController().ensureBuiltIn(
+ "resource://android/assets/reader/", "narou-reader-android@penguin.local");
+ }
+ return runtime;
+ }
+
+ public GeckoResult<WebExtension> extension() { runtime(); return extension; }
+}
diff --git a/app/src/main/java/net/astropenguin/naroureader/TiltSensor.java b/app/src/main/java/net/astropenguin/naroureader/TiltSensor.java
new file mode 100644
index 0000000..d7c5d37
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/naroureader/TiltSensor.java
@@ -0,0 +1,83 @@
+package net.astropenguin.naroureader;
+
+import android.app.Activity;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.view.Surface;
+import android.view.WindowManager;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/** On-demand, screen-relative accelerometer samples. Never runs in the background. */
+final class TiltSensor implements SensorEventListener {
+ private final Activity activity;
+ private final SensorManager manager;
+ private final Sensor sensor;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private final Runnable timeout = this::stop;
+ private boolean listening;
+ private long sampleTime;
+ private float x;
+ private int rotation = -1;
+
+ TiltSensor(Activity activity) {
+ this.activity = activity;
+ manager = (SensorManager) activity.getSystemService(Activity.SENSOR_SERVICE);
+ sensor = manager == null ? null : manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ }
+
+ GeckoBundle sample(boolean enabled, boolean moving) {
+ if (!enabled) stop();
+ else if (sensor != null) {
+ if (!listening) {
+ sampleTime = 0;
+ listening = manager.registerListener(this, sensor, 50_000);
+ }
+ handler.removeCallbacks(timeout);
+ handler.postDelayed(timeout, 1000); // Also stops if a document disappears or JS stalls.
+ }
+ boolean fresh = listening && sampleTime != 0 && SystemClock.elapsedRealtime() - sampleTime < 500;
+ if (fresh && moving) activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ else activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ // GeckoView requires GeckoBundle for outgoing structured replies.
+ GeckoBundle result = new GeckoBundle(4);
+ result.putBoolean("available", sensor != null);
+ result.putBoolean("active", enabled && fresh);
+ result.putDouble("x", x);
+ result.putInt("rotation", rotation);
+ return result;
+ }
+
+ void stop() {
+ if (listening) manager.unregisterListener(this);
+ listening = false;
+ sampleTime = 0;
+ handler.removeCallbacks(timeout);
+ activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override public void onSensorChanged(SensorEvent event) {
+ int nextRotation = activity.getWindowManager().getDefaultDisplay().getRotation();
+ float raw;
+ switch (nextRotation) {
+ case Surface.ROTATION_90: raw = -event.values[1]; break;
+ case Surface.ROTATION_180: raw = -event.values[0]; break;
+ case Surface.ROTATION_270: raw = event.values[1]; break;
+ default: raw = event.values[0];
+ }
+ raw = Math.max(-1f, Math.min(1f, raw / SensorManager.GRAVITY_EARTH));
+ long now = SystemClock.elapsedRealtime();
+ // Small low-pass filter rejects hand tremor, reset on resume or rotation.
+ float weight = (float) (1 - Math.exp(-(now - sampleTime) / 100.0));
+ x = sampleTime == 0 || rotation != nextRotation ? raw : x + weight * (raw - x);
+ rotation = nextRotation;
+ sampleTime = now;
+ }
+
+ @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { }
+}
diff --git a/app/src/main/res/drawable/ic_reader.xml b/app/src/main/res/drawable/ic_reader.xml
new file mode 100644
index 0000000..9391815
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader.xml
@@ -0,0 +1,4 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="96dp" android:height="96dp" android:viewportWidth="96" android:viewportHeight="96">
+ <path android:fillColor="#25483f" android:pathData="M0,0H96V96H0Z" />
+ <path android:fillColor="#fff6df" android:pathData="M60,18H70V78H60ZM43,18H51V66H43ZM26,18H34V78H26Z" />
+</vector>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..2cc4a2f
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,10 @@
+<resources>
+ <style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
+ <item name="android:fontFamily">sans</item>
+ <item name="android:colorAccent">#39866e</item>
+ <item name="android:windowLightStatusBar">false</item>
+ <item name="android:windowActionModeOverlay">true</item>
+ <item name="android:windowBackground">#f6f0e3</item>
+ <item name="android:windowNoTitle">true</item>
+ </style>
+</resources>
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..bb23f61
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1 @@
+plugins { id 'com.android.application' version '9.4.1' apply false }
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..ea48f22
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+brew list --versions openjdk
+
+export JAVA_HOME=$(brew --prefix)/opt/openjdk@27
+export ANDROID_HOME="$HOME/.rbash/lib/Android"
+export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+
+./gradlew assembleDebug
+
+adb install -r app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..da93830
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
+org.gradle.workers.max=2
+android.useAndroidX=true
+android.nonTransitiveRClass=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..9fa7f28
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a78c88e
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,10 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..03754c4
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,112 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+@rem Catch executions from older scripts and ensure they exit cleanly.
+@rem This can be removed once we can be reasonably confident that few people
+@rem will be migrating directly to this new wrapper.
+goto afterSafetyNet
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+goto exitWithErrorLevel
+:afterSafetyNet
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
+
+@rem This label must not be changed. We rely on old scripts being able to jump to this point.
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..6e4c4ab
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,11 @@
+pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri('https://maven.mozilla.org/maven2/') }
+ }
+}
+rootProject.name = 'NarouReader'
+include ':app'
diff --git a/signing/README.md b/signing/README.md
new file mode 100644
index 0000000..983c9ab
--- /dev/null
+++ b/signing/README.md
@@ -0,0 +1,9 @@
+This development keystore signs the supplied APK. Keeping it allows your own
+builds to update the installed app without removing its saved data.
+
+Alias: androiddebugkey
+Store/key password: android
+
+This is a development signing key, included deliberately for this personal
+project. Do not use it for public production releases. For publication, create
+and protect your own release key and use a separate application ID if needed.
diff --git a/tests/motion.test.cjs b/tests/motion.test.cjs
new file mode 100644
index 0000000..b3647a4
--- /dev/null
+++ b/tests/motion.test.cjs
@@ -0,0 +1,15 @@
+const assert = require('node:assert/strict');
+const Motion = require('../app/src/main/assets/reader/motion.js');
+const c={acceleration:900,speed:120,deadzone:.08,brake:.15,neutral:0,reverse:false};
+function run(hz,x,cfg=c,seconds=2){const m=new Motion();let d=0;for(let i=0;i<hz*seconds;i++)d+=m.step(x,1/hz,cfg);return {m,d};}
+assert.equal(run(60,.04).d,0);
+assert.equal(run(60,.54,{...c,neutral:.5}).d,0);
+assert(run(60,.5).d>100);
+assert.equal(run(60,.5).m.velocity,120);
+assert.equal(run(60,-.5).m.velocity,-120);
+assert.equal(run(60,.5,{...c,reverse:true}).d,-run(60,.5).d);
+assert(Math.abs(run(60,.5).d-run(120,.5).d)<.1,'refresh-rate independence');
+const {m}=run(60,.5);for(let i=0;i<120;i++)m.step(0,1/60,c);assert.equal(m.velocity,0);
+m.step(.8,.02,c);assert(m.velocity>0);assert.equal(m.step(.8,2,c),0);assert.equal(m.velocity,0);
+assert.equal(m.step(NaN,.02,c),0);
+console.log('PASS: dead zone, calibration, acceleration, cap, reverse, braking, refresh rate, stale frame');
diff --git a/tests/reader.test.cjs b/tests/reader.test.cjs
new file mode 100644
index 0000000..e3648fb
--- /dev/null
+++ b/tests/reader.test.cjs
@@ -0,0 +1,104 @@
+const fs=require('fs'), path=require('path'), assert=require('assert').strict;
+const {firefox}=require(process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES ? process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES+'/playwright' : 'playwright');
+const ROOT=path.resolve(__dirname,'..');
+(async()=>{
+ const browser=await firefox.launch({headless:true,env:{...process.env,MOZ_DISABLE_CONTENT_SANDBOX:'1'}});
+ const context=await browser.newContext({viewport:{width:412,height:840},hasTouch:true});
+ const data={},messages=[],errors=[];
+ let sensor={available:true,active:true,x:0,rotation:0};
+ await context.exposeFunction('readStore',async keys=>keys===null?data:Object.fromEntries((Array.isArray(keys)?keys:[keys]).filter(k=>k in data).map(k=>[k,data[k]])));
+ await context.exposeFunction('writeStore',async values=>Object.assign(data,values));
+ await context.exposeFunction('removeStore',async keys=>keys.forEach(k=>delete data[k]));
+ await context.exposeFunction('nativeMessage',async (_name,msg)=>{messages.push(msg);return msg.type==='motion'?{...sensor}:null;});
+ await context.addInitScript(()=>{window.browser={storage:{local:{get:window.readStore,set:window.writeStore,remove:window.removeStore}},runtime:{sendNativeMessage:window.nativeMessage,onMessage:{addListener:fn=>{window.toggleReader=fn;}}}};});
+ const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
+ const html='<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><h1 class="p-novel__title">縦書きのテスト</h1><div class="c-pager"><a href="https://ncode.syosetu.com/n0552ep/74/">前へ</a><a href="https://ncode.syosetu.com/n0552ep/">目次</a><a href="https://ncode.syosetu.com/n0552ep/76/">次へ</a></div><div class="p-novel__text">'+Array.from({length:100},(_,i)=>`<p> これは第${i+1}段落です。<ruby>読書<rt>どくしょ</rt></ruby>の時間を、静かに楽しみます。縦書きの列は右から左へ進みます。</p><p><br><br></p><p> <span><br></span></p>`).join('')+'</div></body></html>';
+ await context.route('**/*',r=>r.request().url()==='https://ncode.syosetu.com/n0552ep/75/'?r.fulfill({contentType:'text/html',body:html}):r.abort());
+ const script=path.join(ROOT,'app/src/main/assets/reader/content.js');
+ async function load(){await page.goto('https://ncode.syosetu.com/n0552ep/75/',{waitUntil:'domcontentloaded'});await page.addScriptTag({path:path.join(path.dirname(script),'motion.js')});await page.addScriptTag({path:script});await page.locator('#narou-tategaki-host #panel').waitFor({state:'visible'});await page.waitForTimeout(250);}
+ await load();
+ const host=page.locator('#narou-tategaki-host'),panel=host.locator('#panel'),v=host.locator('#viewport');
+ assert((await panel.getAttribute('class')).includes('chrome-hidden'));
+ assert.equal(await v.evaluate(e=>e.clientHeight),840);
+ assert.equal(await panel.locator('article p').evaluateAll(ps=>ps.filter(p=>!p.textContent.trim()&&!p.querySelector('img')).length),0);
+ assert(messages.some(m=>m.type==='reader'&&m.open));
+ assert(messages.some(m=>m.type==='theme'&&m.theme==='paper'));
+ const width=await v.evaluate(e=>e.scrollWidth);
+ await page.touchscreen.tap(210,350);assert(!(await panel.getAttribute('class')).includes('chrome-hidden'));
+ assert.equal(await v.evaluate(e=>e.scrollWidth),width,'controls must not reflow text');
+ await panel.locator('#paragraph').evaluate(e=>{e.value='1.2';e.dispatchEvent(new Event('input'));});
+ await page.waitForTimeout(100);assert.equal(data.narouTategaki.paragraph,1.2);
+ await panel.locator('select').selectOption('white');
+ assert(messages.some(m=>m.type==='theme'&&m.theme==='white'));
+ await panel.locator('select').selectOption('dark');
+ assert(messages.some(m=>m.type==='theme'&&m.theme==='dark'));
+ await panel.getByRole('button',{name:'App menu'}).click();assert(messages.some(m=>m.type==='menu'));
+ await panel.getByRole('button',{name:'読書に戻る'}).click();
+ await v.focus();await page.keyboard.press('PageDown');await page.keyboard.press('PageDown');
+ await page.waitForTimeout(400);
+ const before=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));assert(before>0);
+ assert(Math.abs(data['narouPosition:/n0552ep/75/'].position-before)<.001);
+ await load();
+ const restored=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));
+ assert(Math.abs(before-restored)<.002,`${before} != ${restored}`);
+ assert.equal(await panel.getAttribute('data-theme'),'dark');
+ assert(messages.filter(m=>m.type==='theme'&&m.theme==='dark').length>=2,'restored theme must reach the native app');
+ await page.touchscreen.tap(210,350);
+ // Optional: inspect with a local screenshot here.
+ await panel.getByRole('button',{name:'元のページ',exact:true}).click();assert(await panel.isHidden());
+ assert(messages.some(m=>m.type==='reader'&&!m.open));
+ await host.locator('#launch').click();await page.waitForTimeout(100);
+ assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
+ await page.setViewportSize({width:840,height:412});await page.waitForTimeout(200);
+ assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
+ assert.equal(await v.evaluate(e=>e.scrollHeight),await v.evaluate(e=>e.clientHeight));
+ // Optional: inspect landscape layout here.
+ // Motion uses mocked native accelerometer samples, real Gecko layout and animation.
+ await page.touchscreen.tap(400,220);
+ await panel.locator('#motion-toggle').click();
+ await page.waitForTimeout(250);
+ await panel.locator('#motion-neutral').click();
+ await panel.getByRole('button',{name:'読書に戻る'}).click();
+ const motionStart=await v.evaluate(e=>e.scrollLeft);
+ sensor.x=.5;
+ await page.waitForTimeout(900);
+ assert(await v.evaluate(e=>e.scrollLeft)<motionStart-20,'tilt must scroll forward');
+ // Opening controls brakes immediately.
+ await page.touchscreen.tap(400,220);
+ const stopped=await v.evaluate(e=>e.scrollLeft);
+ await page.waitForTimeout(350);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),stopped,'controls must pause motion');
+ sensor.x=0;
+ await page.waitForTimeout(200);
+ await panel.locator('#motion-neutral').click();
+ await panel.locator('#motion-reverse').check();
+ await panel.getByRole('button',{name:'読書に戻る'}).click();
+ sensor.x=.5;
+ await page.waitForTimeout(800);
+ assert(await v.evaluate(e=>e.scrollLeft)>stopped+15,'reverse must change direction');
+ sensor.active=false;
+ await page.waitForTimeout(250);
+ const inactive=await v.evaluate(e=>e.scrollLeft);
+ await page.waitForTimeout(300);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'inactive sensor must stop');
+ sensor.active=true;sensor.rotation=1;sensor.x=-.6;
+ await page.waitForTimeout(350);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'rotation must reset neutral');
+ await page.touchscreen.tap(400,220);
+ await panel.locator('#motion-toggle').click();
+ assert(messages.some(m=>m.type==='motion'&&!m.enabled));
+ await load();
+ assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'false','disabled state persists');
+ assert.equal(data.narouMotion.reverse,true,'motion preferences persist');
+ await page.touchscreen.tap(400,220);
+ await panel.locator('#motion-toggle').click();
+ await page.waitForTimeout(150);
+ await load();
+ assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','enabled state persists');
+ const calibrated=await v.evaluate(e=>e.scrollLeft);
+ await page.waitForTimeout(300);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),calibrated,'restored enabled state calibrates without jumping');
+ assert.equal(errors.length,0,errors.join('\n'));
+ console.log(JSON.stringify({result:'PASS',engine:await browser.version(),checks:['spacer paragraphs removed','paragraph control saved','tap toggles controls without reflow','native menu bridge','full viewport reading','reading position restored after reload and rotation','theme persisted','original page toggle','accelerometer scrolling','controls brake motion','reverse direction','inactive sensor stops motion','rotation recalibrates','motion preferences persist']}));
+ await browser.close();
+})().catch(e=>{console.error(e);process.exit(1)});