diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fa2e4ea..0b6ede7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "com.aletheia.app"
minSdk = 24
targetSdk = 36
- versionCode = 37
- versionName = "2.26"
+ versionCode = 42
+ versionName = "2.31"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9d42d94..6041b09 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,9 +15,24 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Aletheia">
+
+
+
+
+
` elements are decoded only for images embedded by the FB2 document itself.
+
+For EPUB, `bookReady` is emitted only after the first rendition has displayed and epub.js locations have either loaded from a valid `cachedLocations` string or finished generating. An invalid cache is ignored and regenerated. A newly generated cache is sent as `paginationCache`.
+
+## Preferences
+
+`ReaderV2.setPreferences(partialPreferences)` merges and applies:
+
+```js
+{
+ fontFamily: "Droid Serif, serif",
+ fontSize: 20, // clamped to 12..42 CSS px
+ lineHeight: 1.58, // clamped to 1.1..2.4
+ margin: 28, // clamped to 8..72 CSS px
+ textAlign: "justify", // justify | left | right | center
+ theme: "light", // light | sepia | dark
+ verticalScroll: false,
+ pageTurnMode: "tapSwipe", // tapSwipe | swipe | tap
+ invertZones: false, // reverses only left/right tap zones
+ brightnessGesture: false // enables vertical left-edge brightness gesture
+}
+```
+
+The left-edge brightness gesture emits `brightnessDelta` with `payload.delta` in percentage points, clamped to `-100..100`; upward is positive. Native code owns the actual screen-brightness value.
+
+The selectable families `Droid Serif`, `EB Garamond`, `Droid Sans`, `Roboto`, `PT Sans`, `PT Serif`, `Merriweather`, and `Open Sans` are bundled in `fonts/`. The shell and every EPUB iframe attach the same font stylesheet, and preference changes wait for the chosen face before repagination. Exact font sources, licenses, hashes, axes, and the Droid Sans italic limitation are recorded in `fonts/PROVENANCE.md`.
+
+Reader canvas colors are fixed to `#FFFFFF` (light), `#F6F3E0` (sepia), and `#000000` (dark).
+
+## Navigation and state
+
+```js
+ReaderV2.next();
+ReaderV2.previous();
+ReaderV2.goToProgress(0.0_to_1.0);
+ReaderV2.goToLocator(locator);
+ReaderV2.goToChapter(href);
+ReaderV2.getStateJson();
+```
+
+Locators:
+
+```js
+{ type: "epub", cfi: "epubcfi(...)" }
+{ type: "fb2", sectionId: "fb2-section-12", offset: 240, endSectionId: "...", endOffset: 278 }
+```
+
+`endSectionId` and `endOffset` are present for an FB2 selection/highlight. Navigation uses its start `sectionId` and `offset`.
+
+Every page action emits `navigation` with `{direction, handled, boundary, locator}`. This event has no control-panel side effect. Only a center tap emits `toggleControls`.
+
+## Search and selection
+
+```js
+ReaderV2.search("query");
+ReaderV2.nextSearch();
+ReaderV2.previousSearch();
+ReaderV2.clearSearch();
+ReaderV2.clearSelection();
+```
+
+Search emits `{query,total,currentIndex,searching,truncated,result}`. At most 500 results are retained; `truncated` reports that cap. A result contains `locator`, `excerpt`, and chapter/href where available.
+
+## Persistent highlights
+
+```js
+ReaderV2.addHighlight(locator, "#ffd84a");
+ReaderV2.removeHighlight(locator);
+ReaderV2.setHighlights([
+ { locator: locatorA, color: "#ffd84a" },
+ { locator: locatorB, color: "#8bd89b" }
+]);
+```
+
+`setHighlights(list)` is the exact bulk-restore method. It replaces all current user highlights with the supplied list and returns a Promise resolving to the restored count. Search markers are transient and are not part of this list.
+
+## Native messages
+
+Every event is sent through:
+
+```js
+AndroidBridge.postMessage(JSON.stringify({ version: 2, type, payload }));
+```
+
+Event types:
+
+- `shellReady` — global API is installed.
+- `bookReady` — the requested book is genuinely displayed and ready.
+- `paginationCache` — `{locations: String}` generated by epub.js.
+- `toc` — `{format, items:[{label,href,depth}]}`.
+- `progress` — format, `progress`, locator, page/chapter fields and boundaries. Both formats expose `chapterCurrentPage`, `chapterTotalPages`, and `remainingInChapter`; the legacy aliases `chapterPage` and `chapterTotal` are retained.
+- `search` — current search state/result.
+- `selection` — selected text and stable locator.
+- `toggleControls` — center reading-zone tap.
+- `navigation` — navigation result/boundary; never opens controls itself.
+- `brightnessDelta` — normalized left-edge gesture delta.
+- `externalLink` — validated `http`, `https`, or `mailto` URL for native handling.
+- `error` — `{stage,code,message,recoverable}`.
diff --git a/app/src/main/assets/reader_v2/css/reader.css b/app/src/main/assets/reader_v2/css/reader.css
new file mode 100644
index 0000000..15c677b
--- /dev/null
+++ b/app/src/main/assets/reader_v2/css/reader.css
@@ -0,0 +1,349 @@
+:root {
+ color-scheme: light;
+ --reader-background: #FFFFFF;
+ --reader-text: #000000;
+ --reader-muted: #77716c;
+ --reader-accent: #e85b20;
+ --reader-selection: rgba(244, 162, 78, .34);
+ --reader-highlight: rgba(255, 216, 74, .52);
+ --reader-font-family: "Droid Serif", "PT Serif", Georgia, serif;
+ --reader-font-size: 20px;
+ --reader-line-height: 1.58;
+ --reader-margin: 28px;
+ --reader-text-align: justify;
+ --reader-page-block-padding: 18px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#reader-shell {
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ background: var(--reader-background);
+}
+
+html,
+body {
+ overscroll-behavior: none;
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ color: var(--reader-text);
+ font-family: var(--reader-font-family);
+ font-optical-sizing: auto;
+ touch-action: pan-y;
+ transition: background-color 140ms ease, color 140ms ease;
+}
+
+body.theme-light {
+ color-scheme: light;
+ --reader-background: #FFFFFF;
+ --reader-text: #000000;
+ --reader-muted: #77716c;
+ --reader-selection: rgba(244, 162, 78, .34);
+}
+
+body.theme-sepia {
+ color-scheme: light;
+ --reader-background: #F6F3E0;
+ --reader-text: #000000;
+ --reader-muted: #8F806B;
+ --reader-selection: rgba(218, 139, 52, .35);
+}
+
+body.theme-dark {
+ color-scheme: dark;
+ --reader-background: #000000;
+ --reader-text: #F4F4F4;
+ --reader-muted: #9D9C9F;
+ --reader-selection: rgba(206, 116, 57, .45);
+ --reader-highlight: rgba(185, 140, 24, .46);
+}
+
+.reader-surface {
+ position: absolute;
+ inset: 0;
+ display: none;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: var(--reader-background);
+ color: var(--reader-text);
+ contain: layout paint;
+}
+
+.reader-surface.is-active {
+ display: block;
+}
+
+#epub-viewer,
+#epub-viewer > div {
+ background: var(--reader-background) !important;
+}
+
+#fb2-viewer.is-scrolled {
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ -webkit-overflow-scrolling: touch;
+}
+
+#fb2-document {
+ position: relative;
+ margin: var(--reader-page-block-padding) var(--reader-margin);
+ padding: 0;
+ color: var(--reader-text);
+ background: var(--reader-background);
+ font-family: var(--reader-font-family);
+ font-size: var(--reader-font-size);
+ font-weight: 400;
+ line-height: var(--reader-line-height);
+ text-align: var(--reader-text-align);
+ hyphens: auto;
+ overflow-wrap: anywhere;
+ word-break: normal;
+ -webkit-user-select: text;
+ user-select: text;
+ -webkit-touch-callout: default;
+ transform-origin: left top;
+ will-change: transform;
+}
+
+#fb2-viewer.is-paginated #fb2-document {
+ height: calc(100% - (2 * var(--reader-page-block-padding)));
+ column-fill: auto;
+ overflow: visible;
+}
+
+#fb2-viewer.is-scrolled #fb2-document {
+ min-height: calc(100% - (2 * var(--reader-page-block-padding)));
+ column-width: auto !important;
+ column-gap: normal !important;
+ transform: none !important;
+ will-change: auto;
+}
+
+#fb2-document p {
+ margin: 0 0 .56em;
+ text-indent: 1.25em;
+}
+
+#fb2-document h1,
+#fb2-document h2,
+#fb2-document h3,
+#fb2-document h4,
+#fb2-document h5,
+#fb2-document h6 {
+ break-after: avoid;
+ color: inherit;
+ font-family: var(--reader-font-family);
+ font-weight: 700;
+ line-height: 1.28;
+ text-align: center;
+ text-wrap: balance;
+}
+
+#fb2-document h1,
+#fb2-document h2 {
+ margin: 1.5em 0 .8em;
+ font-size: 1.38em;
+}
+
+#fb2-document h3,
+#fb2-document h4 {
+ margin: 1.25em 0 .65em;
+ font-size: 1.12em;
+}
+
+#fb2-document .fb2-section:first-child > :first-child {
+ margin-top: .35em;
+}
+
+#fb2-document .fb2-subtitle {
+ margin: .9em 0 .65em;
+ font-size: 1.03em;
+ font-weight: 600;
+ text-align: center;
+ text-indent: 0;
+}
+
+#fb2-document .fb2-epigraph,
+#fb2-document .fb2-cite {
+ margin: 1.05em 0 1.05em 12%;
+ font-size: .92em;
+}
+
+#fb2-document .fb2-epigraph {
+ font-style: italic;
+}
+
+#fb2-document .fb2-cite {
+ padding-left: .9em;
+ border-left: 2px solid color-mix(in srgb, var(--reader-text) 22%, transparent);
+}
+
+#fb2-document .fb2-epigraph p,
+#fb2-document .fb2-cite p,
+#fb2-document .fb2-poem p,
+#fb2-document .fb2-code {
+ text-indent: 0;
+}
+
+#fb2-document .fb2-text-author {
+ text-align: right;
+ text-indent: 0;
+}
+
+#fb2-document .fb2-poem {
+ width: fit-content;
+ max-width: 92%;
+ margin: 1em auto;
+ text-align: left;
+}
+
+#fb2-document .fb2-stanza {
+ margin: 0 0 .9em;
+}
+
+#fb2-document .fb2-stanza p {
+ margin: 0;
+}
+
+#fb2-document .fb2-code {
+ display: block;
+ max-width: 100%;
+ margin: .9em 0;
+ padding: .75em;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ border-radius: 3px;
+ background: color-mix(in srgb, var(--reader-text) 7%, transparent);
+ font-family: ui-monospace, "Cascadia Mono", monospace;
+ font-size: .84em;
+ line-height: 1.45;
+ text-align: left;
+}
+
+#fb2-document .fb2-image {
+ display: block;
+ max-width: min(100%, 760px);
+ max-height: 76vh;
+ margin: 1em auto;
+ object-fit: contain;
+ break-inside: avoid;
+}
+
+#fb2-document .fb2-table-wrap {
+ max-width: 100%;
+ margin: .9em 0;
+ overflow: hidden;
+ break-inside: avoid;
+}
+
+#fb2-document table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: .82em;
+ line-height: 1.35;
+ text-align: left;
+}
+
+#fb2-document th,
+#fb2-document td {
+ padding: .35em .45em;
+ border: 1px solid color-mix(in srgb, var(--reader-text) 24%, transparent);
+ vertical-align: top;
+}
+
+#fb2-document a {
+ color: var(--reader-accent);
+ text-decoration-color: color-mix(in srgb, var(--reader-accent) 55%, transparent);
+ text-underline-offset: .14em;
+}
+
+#fb2-document mark.reader-highlight {
+ padding: 0;
+ border-radius: .08em;
+ color: inherit;
+ background: var(--reader-highlight);
+}
+
+#fb2-document mark.reader-search-current {
+ padding: 0;
+ border-radius: .08em;
+ color: inherit;
+ background: rgba(238, 101, 37, .42);
+ outline: 1px solid rgba(214, 72, 18, .56);
+}
+
+::selection {
+ color: inherit;
+ background: var(--reader-selection);
+}
+
+#reader-status {
+ position: absolute;
+ z-index: 20;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 32px;
+ color: var(--reader-muted);
+ background: var(--reader-background);
+ font: 500 15px/1.4 system-ui, sans-serif;
+ text-align: center;
+ opacity: 1;
+ visibility: visible;
+ transition: opacity 140ms ease, visibility 140ms ease;
+}
+
+#reader-status.is-hidden {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+#reader-status.is-error {
+ color: #b33a27;
+}
+
+#reader-status.is-error .status-spinner {
+ display: none;
+}
+
+.status-spinner {
+ width: 18px;
+ height: 18px;
+ flex: 0 0 auto;
+ border: 2px solid color-mix(in srgb, var(--reader-muted) 28%, transparent);
+ border-top-color: var(--reader-accent);
+ border-radius: 50%;
+ animation: reader-spin .75s linear infinite;
+}
+
+@keyframes reader-spin {
+ to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0ms !important;
+ animation-duration: 0ms !important;
+ }
+}
diff --git a/app/src/main/assets/reader_v2/fonts/PROVENANCE.md b/app/src/main/assets/reader_v2/fonts/PROVENANCE.md
new file mode 100644
index 0000000..808eb58
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/PROVENANCE.md
@@ -0,0 +1,43 @@
+# Reader font provenance
+
+All font binaries in this directory are copied without modification from an official upstream repository. Local variable-font files use bracket-free names because PowerShell treats square brackets as wildcard syntax; the upstream path and immutable blob identifier are recorded below.
+
+## Google Fonts families
+
+Source: [google/fonts](https://github.com/google/fonts) at commit [`389b770410cc0b7c21c85673bfa2077420fe7f65`](https://github.com/google/fonts/commit/389b770410cc0b7c21c85673bfa2077420fe7f65), committed 2026-07-16. Every family directory retains the upstream `METADATA.pb` and `OFL.txt`.
+
+| Local file | Upstream path | Git blob SHA | SHA-256 |
+|---|---|---|---|
+| `eb_garamond/EBGaramond-Variable.ttf` | `ofl/ebgaramond/EBGaramond[wght].ttf` | `52d34b86ec20977f222ce37e8158e6b3002c0a31` | `ef9512f92f6d579e5dc75af59a5a4b1b8b47d2eda89e00b954d44520e5369027` |
+| `eb_garamond/EBGaramond-VariableItalic.ttf` | `ofl/ebgaramond/EBGaramond-Italic[wght].ttf` | `f9ad996001c8ee72fd43b4b8e67c8924b7f615b1` | `bba2c4499c93c9612b90b9825d32b07da52fce2fe57562a1eb6b833553f93c4e` |
+| `merriweather/Merriweather-Variable.ttf` | `ofl/merriweather/Merriweather[opsz,wdth,wght].ttf` | `cb775b80fa0a6f3b2aea114869a7171695b50a62` | `d0ed0e359e396af7ad05e73dffd11a3a4c326ea0d0283c56bd9361cb2cc86a96` |
+| `merriweather/Merriweather-VariableItalic.ttf` | `ofl/merriweather/Merriweather-Italic[opsz,wdth,wght].ttf` | `f16120757a8a92cfa12a5fc9a7dcbaa2f8297ab6` | `f68a8f4989258679e4fbaf50aa42400132b5373c2d9d2514ba82ef6e85947a0b` |
+| `open_sans/OpenSans-Variable.ttf` | `ofl/opensans/OpenSans[wdth,wght].ttf` | `9db85693b027f3b05f6d77471d215f20707127c1` | `36643644f318a812aab2d2ed3bb98f8cf0872527f835fe9398d95fe6b9adb878` |
+| `open_sans/OpenSans-VariableItalic.ttf` | `ofl/opensans/OpenSans-Italic[wdth,wght].ttf` | `6c2997999ce43d4ab5b377b712f2b10aca5e4bc1` | `fe269381e992f32e135801740998544d6235061e37c93ec067ad2be3edd5b17b` |
+| `pt_sans/PT_Sans-Web-Regular.ttf` | `ofl/ptsans/PT_Sans-Web-Regular.ttf` | `83a21b724c47127a95e725b5c6bdfa30fcc1a7be` | `9cc831490532009bae2b3ce0d39c62adfc889060beb421593bfd9d2396d0f10a` |
+| `pt_sans/PT_Sans-Web-Italic.ttf` | `ofl/ptsans/PT_Sans-Web-Italic.ttf` | `180a5d68d3f9979aa58d59ff7c775d7aac095573` | `5a90fe2d0cd798700935240580bdcc12c0ffc9102c0c7163b3418e13bc21debd` |
+| `pt_sans/PT_Sans-Web-Bold.ttf` | `ofl/ptsans/PT_Sans-Web-Bold.ttf` | `3d4e6fe2f9f5282806cda0553d3ab321605ae37c` | `3128bd5ecf01816e59a23d54c57a7a6b14615b07db53ff277c77376010265b05` |
+| `pt_sans/PT_Sans-Web-BoldItalic.ttf` | `ofl/ptsans/PT_Sans-Web-BoldItalic.ttf` | `eb61f14b6b07a0e278374a4b551ccca8bd7a21fc` | `81ac221cdd02bccfa679c74adb122478e9d092e65a722e31ca11469961483785` |
+| `pt_serif/PT_Serif-Web-Regular.ttf` | `ofl/ptserif/PT_Serif-Web-Regular.ttf` | `5310691a999658437d6db608b9447d606598753e` | `a4951fade06ff8f09b7673aa81ffb65a8cd409e24d3289a6dc670bc4dda2557a` |
+| `pt_serif/PT_Serif-Web-Italic.ttf` | `ofl/ptserif/PT_Serif-Web-Italic.ttf` | `b690e26058970205f7cb1bcb4434827281a1707c` | `f57e95ff9dc85691a3b2e193f2028db36f6663939a46c0fc4f286d618b80b7ce` |
+| `pt_serif/PT_Serif-Web-Bold.ttf` | `ofl/ptserif/PT_Serif-Web-Bold.ttf` | `0483e59fa9d9669c5e98a2b2a1b7cd059db98888` | `038ba7336bd7ea14f12ad155bed51a4345cac5153275d521dec3ba04021c526e` |
+| `pt_serif/PT_Serif-Web-BoldItalic.ttf` | `ofl/ptserif/PT_Serif-Web-BoldItalic.ttf` | `49d504b59335962a547ef7fa6ba74040ebf3521a` | `f003788ba08981eb0988b3557a6f224a53dab49c20e283e8b74d5af3c466f8be` |
+| `roboto/Roboto-Variable.ttf` | `ofl/roboto/Roboto[wdth,wght].ttf` | `5522a368d9072fd88c299916e61fcff369949061` | `d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134` |
+| `roboto/Roboto-VariableItalic.ttf` | `ofl/roboto/Roboto-Italic[wdth,wght].ttf` | `a122c13964daac37f1186a22603a5180d2dba5d6` | `9725a847af6b460ffca162ae66d20dad48b01876137947180b42d7dcd7887182` |
+
+## Droid families
+
+Source: [AOSP `platform/frameworks/base`](https://android.googlesource.com/platform/frameworks/base/) at commit [`b046b20f1a5406ddda60c548036ed9e887da49df`](https://android.googlesource.com/platform/frameworks/base/+/b046b20f1a5406ddda60c548036ed9e887da49df), directory `data/fonts`. The upstream `NOTICE` and `MODULE_LICENSE_APACHE2` files are retained under `licenses/aosp`.
+
+| Local file | Upstream file | Git blob SHA | SHA-256 |
+|---|---|---|---|
+| `droid_sans/DroidSans.ttf` | `DroidSans.ttf` | `ad1efca88aed8d9e2d179f27dd713e2a1562fe5f` | `f51b88945f4c1b236f44b8d55a2d304316869127e95248c435c23f1e4142a7db` |
+| `droid_sans/DroidSans-Bold.ttf` | `DroidSans-Bold.ttf` | `d065b64eb1863f83c2c982264f9442f2313a44a9` | `2f529a3e60c007979d95d29794c3660694217fb882429fb33919d2245fe969e9` |
+| `droid_serif/DroidSerif-Regular.ttf` | `DroidSerif-Regular.ttf` | `5b4fe815d2d856632c200f9b10c45cbc814c86f4` | `ae32140265dbe0dfde24b9abd222be9210a531888f014ff9b2326aba2d6fd777` |
+| `droid_serif/DroidSerif-Italic.ttf` | `DroidSerif-Italic.ttf` | `2972809daaa8d600a3509389124c428492d4868e` | `02c0108dea583e393fbcbec39c7093e9a3095a09d65e37fafff9a9b7a89123a4` |
+| `droid_serif/DroidSerif-Bold.ttf` | `DroidSerif-Bold.ttf` | `838d255888b41224581339799f83e1f6f3f3dc41` | `c746c6383a03effbcad6ccb028e4b39e87071ff1a146a24fb2110a23e05776bb` |
+| `droid_serif/DroidSerif-BoldItalic.ttf` | `DroidSerif-BoldItalic.ttf` | `0b1601f61bd7fe0697ee3228841bfa8b5f49ec70` | `1aba99421b31afe890182663270c28bc619f00d8746234da3dda054cd5bf09b0` |
+
+## Local verification
+
+The binary inspection checks the TTF scaler signature (`00 01 00 00`), family/subfamily names, OS/2 weight, variable axes, and Unicode `cmap`. Every bundled file contains U+0401, U+0451, U+0410–U+042F, and U+0430–U+044F. Droid Sans has no official italic file in the pinned AOSP directory, so its italic text is intentionally synthesized by WebView; every other selectable family has a real italic face.
diff --git a/app/src/main/assets/reader_v2/fonts/droid_sans/DroidSans-Bold.ttf b/app/src/main/assets/reader_v2/fonts/droid_sans/DroidSans-Bold.ttf
new file mode 100644
index 0000000..d065b64
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/droid_sans/DroidSans-Bold.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/droid_sans/DroidSans.ttf b/app/src/main/assets/reader_v2/fonts/droid_sans/DroidSans.ttf
new file mode 100644
index 0000000..ad1efca
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/droid_sans/DroidSans.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Bold.ttf b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Bold.ttf
new file mode 100644
index 0000000..838d255
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Bold.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-BoldItalic.ttf b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-BoldItalic.ttf
new file mode 100644
index 0000000..0b1601f
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-BoldItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Italic.ttf b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Italic.ttf
new file mode 100644
index 0000000..2972809
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Italic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Regular.ttf b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Regular.ttf
new file mode 100644
index 0000000..5b4fe81
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/droid_serif/DroidSerif-Regular.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/eb_garamond/EBGaramond-Variable.ttf b/app/src/main/assets/reader_v2/fonts/eb_garamond/EBGaramond-Variable.ttf
new file mode 100644
index 0000000..52d34b8
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/eb_garamond/EBGaramond-Variable.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/eb_garamond/EBGaramond-VariableItalic.ttf b/app/src/main/assets/reader_v2/fonts/eb_garamond/EBGaramond-VariableItalic.ttf
new file mode 100644
index 0000000..f9ad996
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/eb_garamond/EBGaramond-VariableItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/eb_garamond/METADATA.pb b/app/src/main/assets/reader_v2/fonts/eb_garamond/METADATA.pb
new file mode 100644
index 0000000..0b77118
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/eb_garamond/METADATA.pb
@@ -0,0 +1,55 @@
+name: "EB Garamond"
+designer: "Georg Duffner, Octavio Pardo"
+license: "OFL"
+category: "SERIF"
+date_added: "2011-03-23"
+fonts {
+ name: "EB Garamond"
+ style: "normal"
+ weight: 400
+ filename: "EBGaramond[wght].ttf"
+ post_script_name: "EBGaramond-Regular"
+ full_name: "EB Garamond Regular"
+ copyright: "Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)"
+}
+fonts {
+ name: "EB Garamond"
+ style: "italic"
+ weight: 400
+ filename: "EBGaramond-Italic[wght].ttf"
+ post_script_name: "EBGaramond-Italic"
+ full_name: "EB Garamond Italic"
+ copyright: "Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)"
+}
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "greek"
+subsets: "greek-ext"
+subsets: "latin"
+subsets: "latin-ext"
+subsets: "menu"
+subsets: "vietnamese"
+axes {
+ tag: "wght"
+ min_value: 400.0
+ max_value: 800.0
+}
+source {
+ repository_url: "https://github.com/octaviopardo/EBGaramond12"
+ commit: "106a4a6d377987459ae5e68673a4570f13b957fb"
+ files {
+ source_file: "fonts/variable/EBGaramond[wght].ttf"
+ dest_file: "EBGaramond[wght].ttf"
+ }
+ files {
+ source_file: "fonts/variable/EBGaramond-Italic[wght].ttf"
+ dest_file: "EBGaramond-Italic[wght].ttf"
+ }
+ files {
+ source_file: "OFL.txt"
+ dest_file: "OFL.txt"
+ }
+ branch: "master"
+ config_yaml: "sources/config.yaml"
+}
+minisite_url: "https://googlefonts.github.io/ebgaramond-specimen/"
diff --git a/app/src/main/assets/reader_v2/fonts/eb_garamond/OFL.txt b/app/src/main/assets/reader_v2/fonts/eb_garamond/OFL.txt
new file mode 100644
index 0000000..d4143a8
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/eb_garamond/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/assets/reader_v2/fonts/fonts.css b/app/src/main/assets/reader_v2/fonts/fonts.css
new file mode 100644
index 0000000..69bca9b
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/fonts.css
@@ -0,0 +1,181 @@
+@font-face {
+ font-family: "Droid Serif";
+ src: url("droid_serif/DroidSerif-Regular.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Droid Serif";
+ src: url("droid_serif/DroidSerif-Italic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Droid Serif";
+ src: url("droid_serif/DroidSerif-Bold.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Droid Serif";
+ src: url("droid_serif/DroidSerif-BoldItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "EB Garamond";
+ src: url("eb_garamond/EBGaramond-Variable.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "EB Garamond";
+ src: url("eb_garamond/EBGaramond-VariableItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 400 800;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Droid Sans";
+ src: url("droid_sans/DroidSans.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Droid Sans";
+ src: url("droid_sans/DroidSans-Bold.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Roboto";
+ src: url("roboto/Roboto-Variable.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 100 900;
+ font-stretch: 75% 100%;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Roboto";
+ src: url("roboto/Roboto-VariableItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 100 900;
+ font-stretch: 75% 100%;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Sans";
+ src: url("pt_sans/PT_Sans-Web-Regular.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Sans";
+ src: url("pt_sans/PT_Sans-Web-Italic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Sans";
+ src: url("pt_sans/PT_Sans-Web-Bold.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Sans";
+ src: url("pt_sans/PT_Sans-Web-BoldItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Serif";
+ src: url("pt_serif/PT_Serif-Web-Regular.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Serif";
+ src: url("pt_serif/PT_Serif-Web-Italic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 400;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Serif";
+ src: url("pt_serif/PT_Serif-Web-Bold.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "PT Serif";
+ src: url("pt_serif/PT_Serif-Web-BoldItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 700;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Merriweather";
+ src: url("merriweather/Merriweather-Variable.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 300 900;
+ font-stretch: 87% 112%;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Merriweather";
+ src: url("merriweather/Merriweather-VariableItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 300 900;
+ font-stretch: 87% 112%;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Open Sans";
+ src: url("open_sans/OpenSans-Variable.ttf") format("truetype");
+ font-style: normal;
+ font-weight: 300 800;
+ font-stretch: 75% 100%;
+ font-display: block;
+}
+
+@font-face {
+ font-family: "Open Sans";
+ src: url("open_sans/OpenSans-VariableItalic.ttf") format("truetype");
+ font-style: italic;
+ font-weight: 300 800;
+ font-stretch: 75% 100%;
+ font-display: block;
+}
diff --git a/app/src/main/assets/reader_v2/fonts/licenses/aosp/MODULE_LICENSE_APACHE2 b/app/src/main/assets/reader_v2/fonts/licenses/aosp/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/assets/reader_v2/fonts/licenses/aosp/NOTICE.txt b/app/src/main/assets/reader_v2/fonts/licenses/aosp/NOTICE.txt
new file mode 100644
index 0000000..f7bd78d
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/licenses/aosp/NOTICE.txt
@@ -0,0 +1,189 @@
+
+ Copyright (c) 2005-2008, The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+
+ 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.
+
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/app/src/main/assets/reader_v2/fonts/merriweather/METADATA.pb b/app/src/main/assets/reader_v2/fonts/merriweather/METADATA.pb
new file mode 100644
index 0000000..4a3fa62
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/merriweather/METADATA.pb
@@ -0,0 +1,291 @@
+name: "Merriweather"
+designer: "Sorkin Type"
+license: "OFL"
+category: "SERIF"
+date_added: "2011-05-11"
+fonts {
+ name: "Merriweather"
+ style: "normal"
+ weight: 400
+ filename: "Merriweather[opsz,wdth,wght].ttf"
+ post_script_name: "Merriweather-Light"
+ full_name: "Merriweather Light"
+ copyright: "Copyright 2024 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name \"Merriweather\"."
+}
+fonts {
+ name: "Merriweather"
+ style: "italic"
+ weight: 400
+ filename: "Merriweather-Italic[opsz,wdth,wght].ttf"
+ post_script_name: "Merriweather-LightItalic"
+ full_name: "Merriweather Light Italic"
+ copyright: "Copyright 2024 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name \"Merriweather\"."
+}
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "latin"
+subsets: "latin-ext"
+subsets: "menu"
+subsets: "vietnamese"
+axes {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 144.0
+}
+axes {
+ tag: "wdth"
+ min_value: 87.0
+ max_value: 112.0
+}
+axes {
+ tag: "wght"
+ min_value: 300.0
+ max_value: 900.0
+}
+registry_default_overrides {
+ key: "opsz"
+ value: 18.0
+}
+source {
+ repository_url: "https://github.com/EbenSorkin/Merriweather4"
+ commit: "e586023aa0fe1dba9a7d4ec80fa8b9e546cb7ecf"
+ archive_url: "https://github.com/EbenSorkin/Merriweather4/releases/download/4.008/Merriweather4-4.008.zip"
+ files {
+ source_file: "fonts/variable/Merriweather[opsz,wdth,wght].ttf"
+ dest_file: "Merriweather[opsz,wdth,wght].ttf"
+ }
+ files {
+ source_file: "fonts/variable/Merriweather-Italic[opsz,wdth,wght].ttf"
+ dest_file: "Merriweather-Italic[opsz,wdth,wght].ttf"
+ }
+ files {
+ source_file: "OFL.txt"
+ dest_file: "OFL.txt"
+ }
+ branch: "main"
+ config_yaml: "sources/config.yaml"
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 300.0
+ max_value: 300.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 109.38
+ local_src: "Roboto"
+ ascent_override_pct: 90.63
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 400.0
+ max_value: 400.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 109.77
+ local_src: "Roboto"
+ ascent_override_pct: 90.63
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 700.0
+ max_value: 700.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 112.3
+ local_src: "Roboto"
+ ascent_override_pct: 87.5
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 900.0
+ max_value: 900.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 114.06
+ local_src: "Roboto"
+ ascent_override_pct: 85.94
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 300.0
+ max_value: 300.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 105.08
+ local_src: "Roboto Italic"
+ ascent_override_pct: 95.31
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 400.0
+ max_value: 400.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 109.38
+ local_src: "Roboto Italic"
+ ascent_override_pct: 90.63
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 700.0
+ max_value: 700.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 108.2
+ local_src: "Roboto Italic"
+ ascent_override_pct: 90.63
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 900.0
+ max_value: 900.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "opsz"
+ min_value: 18.0
+ max_value: 18.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 112.5
+ local_src: "Roboto Italic"
+ ascent_override_pct: 87.5
+}
diff --git a/app/src/main/assets/reader_v2/fonts/merriweather/Merriweather-Variable.ttf b/app/src/main/assets/reader_v2/fonts/merriweather/Merriweather-Variable.ttf
new file mode 100644
index 0000000..cb775b8
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/merriweather/Merriweather-Variable.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/merriweather/Merriweather-VariableItalic.ttf b/app/src/main/assets/reader_v2/fonts/merriweather/Merriweather-VariableItalic.ttf
new file mode 100644
index 0000000..f161207
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/merriweather/Merriweather-VariableItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/merriweather/OFL.txt b/app/src/main/assets/reader_v2/fonts/merriweather/OFL.txt
new file mode 100644
index 0000000..e5149cc
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/merriweather/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/assets/reader_v2/fonts/open_sans/METADATA.pb b/app/src/main/assets/reader_v2/fonts/open_sans/METADATA.pb
new file mode 100644
index 0000000..fba7c82
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/open_sans/METADATA.pb
@@ -0,0 +1,291 @@
+name: "Open Sans"
+designer: "Steve Matteson"
+license: "OFL"
+category: "SANS_SERIF"
+date_added: "2011-02-02"
+fonts {
+ name: "Open Sans"
+ style: "normal"
+ weight: 400
+ filename: "OpenSans[wdth,wght].ttf"
+ post_script_name: "OpenSans-Regular"
+ full_name: "Open Sans Regular"
+ copyright: "Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)"
+}
+fonts {
+ name: "Open Sans"
+ style: "italic"
+ weight: 400
+ filename: "OpenSans-Italic[wdth,wght].ttf"
+ post_script_name: "OpenSans-Italic"
+ full_name: "Open Sans Italic"
+ copyright: "Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)"
+}
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "greek"
+subsets: "greek-ext"
+subsets: "hebrew"
+subsets: "latin"
+subsets: "latin-ext"
+subsets: "math"
+subsets: "menu"
+subsets: "symbols"
+subsets: "vietnamese"
+axes {
+ tag: "wdth"
+ min_value: 75.0
+ max_value: 100.0
+}
+axes {
+ tag: "wght"
+ min_value: 300.0
+ max_value: 800.0
+}
+source {
+ repository_url: "https://github.com/googlefonts/opensans"
+ commit: "bd7e37632246368c60fdcbd374dbf9bad11969b6"
+ files {
+ source_file: "fonts/variable/OpenSans[wdth,wght].ttf"
+ dest_file: "OpenSans[wdth,wght].ttf"
+ }
+ files {
+ source_file: "fonts/variable/OpenSans-Italic[wdth,wght].ttf"
+ dest_file: "OpenSans-Italic[wdth,wght].ttf"
+ }
+ files {
+ source_file: "OFL.txt"
+ dest_file: "OFL.txt"
+ }
+ branch: "main"
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 300.0
+ max_value: 300.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 100.0
+ local_src: "Roboto"
+ ascent_override_pct: 106.25
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 400.0
+ max_value: 400.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 103.13
+ local_src: "Roboto"
+ ascent_override_pct: 107.81
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 600.0
+ max_value: 600.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 105.08
+ local_src: "Roboto"
+ ascent_override_pct: 100.0
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 700.0
+ max_value: 700.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 108.59
+ local_src: "Roboto"
+ ascent_override_pct: 100.0
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 800.0
+ max_value: 800.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 0.0
+ max_value: 0.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 111.72
+ local_src: "Roboto"
+ ascent_override_pct: 100.0
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 300.0
+ max_value: 300.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 95.7
+ local_src: "Roboto Italic"
+ ascent_override_pct: 112.5
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 400.0
+ max_value: 400.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 99.22
+ local_src: "Roboto Italic"
+ ascent_override_pct: 125.0
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 600.0
+ max_value: 600.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 102.34
+ local_src: "Roboto Italic"
+ ascent_override_pct: 107.81
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 700.0
+ max_value: 700.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 105.08
+ local_src: "Roboto Italic"
+ ascent_override_pct: 100.0
+}
+fallbacks {
+ axis_target {
+ tag: "wght"
+ min_value: 800.0
+ max_value: 800.0
+ }
+ axis_target {
+ tag: "wdth"
+ min_value: 100.0
+ max_value: 100.0
+ }
+ axis_target {
+ tag: "ital"
+ min_value: 1.0
+ max_value: 1.0
+ }
+ target {
+ target_type: TARGET_OS_ANDROID
+ }
+ size_adjust_pct: 110.94
+ local_src: "Roboto Italic"
+ ascent_override_pct: 98.44
+}
diff --git a/app/src/main/assets/reader_v2/fonts/open_sans/OFL.txt b/app/src/main/assets/reader_v2/fonts/open_sans/OFL.txt
new file mode 100644
index 0000000..d762c3c
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/open_sans/OFL.txt
@@ -0,0 +1,92 @@
+Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/assets/reader_v2/fonts/open_sans/OpenSans-Variable.ttf b/app/src/main/assets/reader_v2/fonts/open_sans/OpenSans-Variable.ttf
new file mode 100644
index 0000000..9db8569
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/open_sans/OpenSans-Variable.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/open_sans/OpenSans-VariableItalic.ttf b/app/src/main/assets/reader_v2/fonts/open_sans/OpenSans-VariableItalic.ttf
new file mode 100644
index 0000000..6c29979
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/open_sans/OpenSans-VariableItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_sans/METADATA.pb b/app/src/main/assets/reader_v2/fonts/pt_sans/METADATA.pb
new file mode 100644
index 0000000..49d4867
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/pt_sans/METADATA.pb
@@ -0,0 +1,46 @@
+name: "PT Sans"
+designer: "ParaType"
+license: "OFL"
+category: "SANS_SERIF"
+date_added: "2010-09-21"
+fonts {
+ name: "PT Sans"
+ style: "normal"
+ weight: 400
+ filename: "PT_Sans-Web-Regular.ttf"
+ post_script_name: "PTSans-Regular"
+ full_name: "PT Sans"
+ copyright: "Copyright © 2009 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+fonts {
+ name: "PT Sans"
+ style: "italic"
+ weight: 400
+ filename: "PT_Sans-Web-Italic.ttf"
+ post_script_name: "PTSans-Italic"
+ full_name: "PT Sans Italic"
+ copyright: "Copyright © 2009 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+fonts {
+ name: "PT Sans"
+ style: "normal"
+ weight: 700
+ filename: "PT_Sans-Web-Bold.ttf"
+ post_script_name: "PTSans-Bold"
+ full_name: "PT Sans Bold"
+ copyright: "Copyright © 2009 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+fonts {
+ name: "PT Sans"
+ style: "italic"
+ weight: 700
+ filename: "PT_Sans-Web-BoldItalic.ttf"
+ post_script_name: "PTSans-BoldItalic"
+ full_name: "PT Sans Bold Italic"
+ copyright: "Copyright © 2009 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+subsets: "menu"
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "latin"
+subsets: "latin-ext"
diff --git a/app/src/main/assets/reader_v2/fonts/pt_sans/OFL.txt b/app/src/main/assets/reader_v2/fonts/pt_sans/OFL.txt
new file mode 100644
index 0000000..297566b
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/pt_sans/OFL.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public),
+with Reserved Font Names "PT Sans" and "ParaType".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Bold.ttf b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Bold.ttf
new file mode 100644
index 0000000..3d4e6fe
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Bold.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-BoldItalic.ttf b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-BoldItalic.ttf
new file mode 100644
index 0000000..eb61f14
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-BoldItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Italic.ttf b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Italic.ttf
new file mode 100644
index 0000000..180a5d6
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Italic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Regular.ttf b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Regular.ttf
new file mode 100644
index 0000000..83a21b7
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_sans/PT_Sans-Web-Regular.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_serif/METADATA.pb b/app/src/main/assets/reader_v2/fonts/pt_serif/METADATA.pb
new file mode 100644
index 0000000..d1e5e67
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/pt_serif/METADATA.pb
@@ -0,0 +1,50 @@
+name: "PT Serif"
+designer: "ParaType"
+license: "OFL"
+category: "SERIF"
+date_added: "2011-02-09"
+fonts {
+ name: "PT Serif"
+ style: "normal"
+ weight: 400
+ filename: "PT_Serif-Web-Regular.ttf"
+ post_script_name: "PTSerif-Regular"
+ full_name: "PT Serif"
+ copyright: "Copyright © 2010 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+fonts {
+ name: "PT Serif"
+ style: "italic"
+ weight: 400
+ filename: "PT_Serif-Web-Italic.ttf"
+ post_script_name: "PTSerif-Italic"
+ full_name: "PT Serif Italic"
+ copyright: "Copyright © 2010 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+fonts {
+ name: "PT Serif"
+ style: "normal"
+ weight: 700
+ filename: "PT_Serif-Web-Bold.ttf"
+ post_script_name: "PTSerif-Bold"
+ full_name: "PT Serif Bold"
+ copyright: "Copyright © 2010 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+fonts {
+ name: "PT Serif"
+ style: "italic"
+ weight: 700
+ filename: "PT_Serif-Web-BoldItalic.ttf"
+ post_script_name: "PTSerif-BoldItalic"
+ full_name: "PT Serif Bold Italic"
+ copyright: "Copyright © 2010 ParaType Ltd (yakupov@paratype.com). All rights reserved."
+}
+subsets: "menu"
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "latin"
+subsets: "latin-ext"
+source {
+ repository_url: "https://github.com/googlefonts/googlefontdirectory-hg"
+ commit: "52f780bc9d197280a9f430574e179a5f233c56b6"
+}
diff --git a/app/src/main/assets/reader_v2/fonts/pt_serif/OFL.txt b/app/src/main/assets/reader_v2/fonts/pt_serif/OFL.txt
new file mode 100644
index 0000000..0a30c66
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/pt_serif/OFL.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public),
+with Reserved Font Names "PT Sans", "PT Serif" and "ParaType".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Bold.ttf b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Bold.ttf
new file mode 100644
index 0000000..0483e59
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Bold.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-BoldItalic.ttf b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-BoldItalic.ttf
new file mode 100644
index 0000000..49d504b
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-BoldItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Italic.ttf b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Italic.ttf
new file mode 100644
index 0000000..b690e26
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Italic.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Regular.ttf b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Regular.ttf
new file mode 100644
index 0000000..5310691
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/pt_serif/PT_Serif-Web-Regular.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/roboto/METADATA.pb b/app/src/main/assets/reader_v2/fonts/roboto/METADATA.pb
new file mode 100644
index 0000000..9ae795d
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/roboto/METADATA.pb
@@ -0,0 +1,57 @@
+name: "Roboto"
+designer: "Christian Robertson, ParaType, Font Bureau"
+license: "OFL"
+category: "SANS_SERIF"
+date_added: "2013-01-09"
+fonts {
+ name: "Roboto"
+ style: "normal"
+ weight: 400
+ filename: "Roboto[wdth,wght].ttf"
+ post_script_name: "Roboto-Regular"
+ full_name: "Roboto Regular"
+ copyright: "Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)"
+}
+fonts {
+ name: "Roboto"
+ style: "italic"
+ weight: 400
+ filename: "Roboto-Italic[wdth,wght].ttf"
+ post_script_name: "Roboto-Italic"
+ full_name: "Roboto Italic"
+ copyright: "Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)"
+}
+subsets: "cyrillic"
+subsets: "cyrillic-ext"
+subsets: "greek"
+subsets: "greek-ext"
+subsets: "latin"
+subsets: "latin-ext"
+subsets: "math"
+subsets: "menu"
+subsets: "symbols"
+subsets: "vietnamese"
+axes {
+ tag: "wdth"
+ min_value: 75.0
+ max_value: 100.0
+}
+axes {
+ tag: "wght"
+ min_value: 100.0
+ max_value: 900.0
+}
+source {
+ repository_url: "https://github.com/googlefonts/roboto-classic"
+ commit: "91d5d3e5b81efa04a77925cc609fdcdd7ee663d1"
+ archive_url: "https://github.com/googlefonts/roboto-3-classic/releases/download/v3.015/Roboto_v3.015.zip"
+ files {
+ source_file: "web/split/Roboto[wdth,wght].ttf"
+ dest_file: "Roboto[wdth,wght].ttf"
+ }
+ files {
+ source_file: "web/split/Roboto-Italic[wdth,wght].ttf"
+ dest_file: "Roboto-Italic[wdth,wght].ttf"
+ }
+ branch: "main"
+}
diff --git a/app/src/main/assets/reader_v2/fonts/roboto/OFL.txt b/app/src/main/assets/reader_v2/fonts/roboto/OFL.txt
new file mode 100644
index 0000000..65a3057
--- /dev/null
+++ b/app/src/main/assets/reader_v2/fonts/roboto/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/assets/reader_v2/fonts/roboto/Roboto-Variable.ttf b/app/src/main/assets/reader_v2/fonts/roboto/Roboto-Variable.ttf
new file mode 100644
index 0000000..5522a36
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/roboto/Roboto-Variable.ttf differ
diff --git a/app/src/main/assets/reader_v2/fonts/roboto/Roboto-VariableItalic.ttf b/app/src/main/assets/reader_v2/fonts/roboto/Roboto-VariableItalic.ttf
new file mode 100644
index 0000000..a122c13
Binary files /dev/null and b/app/src/main/assets/reader_v2/fonts/roboto/Roboto-VariableItalic.ttf differ
diff --git a/app/src/main/assets/reader_v2/index.html b/app/src/main/assets/reader_v2/index.html
new file mode 100644
index 0000000..574b280
--- /dev/null
+++ b/app/src/main/assets/reader_v2/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ Читалка
+
+
+
+
+
+
+
+
+
+ Подготовка читалки…
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/reader_v2/js/core.js b/app/src/main/assets/reader_v2/js/core.js
new file mode 100644
index 0000000..fbe05e6
--- /dev/null
+++ b/app/src/main/assets/reader_v2/js/core.js
@@ -0,0 +1,367 @@
+(function (global) {
+ 'use strict';
+
+ const Internal = global.ReaderV2Internal = global.ReaderV2Internal || {};
+
+ const DEFAULT_PREFERENCES = Object.freeze({
+ fontFamily: 'Droid Serif, PT Serif, Georgia, serif',
+ fontSize: 20,
+ lineHeight: 1.58,
+ margin: 28,
+ textAlign: 'justify',
+ theme: 'light',
+ verticalScroll: false,
+ pageTurnMode: 'tapSwipe',
+ invertZones: false,
+ brightnessGesture: false
+ });
+
+ const THEMES = Object.freeze({
+ light: { background: '#FFFFFF', text: '#000000', muted: '#77716c' },
+ sepia: { background: '#F6F3E0', text: '#000000', muted: '#8F806B' },
+ dark: { background: '#000000', text: '#F4F4F4', muted: '#9D9C9F' }
+ });
+
+ const READER_FONT_STYLESHEET_URL = 'https://appassets.androidplatform.net/assets/reader_v2/fonts/fonts.css';
+ const READER_FONT_STYLESHEET_ID = 'reader-v2-font-faces';
+ const FONT_STACKS = Object.freeze({
+ 'Droid Serif': '"Droid Serif", "PT Serif", Georgia, serif',
+ 'EB Garamond': '"EB Garamond", "PT Serif", Georgia, serif',
+ 'Droid Sans': '"Droid Sans", Roboto, sans-serif',
+ 'Roboto': 'Roboto, "Open Sans", sans-serif',
+ 'PT Sans': '"PT Sans", Roboto, sans-serif',
+ 'PT Serif': '"PT Serif", Georgia, serif',
+ 'Merriweather': 'Merriweather, "PT Serif", Georgia, serif',
+ 'Open Sans': '"Open Sans", Roboto, sans-serif'
+ });
+ const BUNDLED_FONT_FAMILIES = Object.freeze(Object.keys(FONT_STACKS));
+
+ function post(type, payload) {
+ const envelope = JSON.stringify({
+ version: 2,
+ type: String(type || ''),
+ payload: payload || {}
+ });
+ try {
+ if (global.AndroidBridge && typeof global.AndroidBridge.postMessage === 'function') {
+ global.AndroidBridge.postMessage(envelope);
+ }
+ } catch (_) {
+ // A missing or detached native bridge must never break reading.
+ }
+ return envelope;
+ }
+
+ function errorPayload(error, stage, recoverable) {
+ const value = error instanceof Error ? error : new Error(String(error || 'Неизвестная ошибка'));
+ return {
+ stage: stage || 'reader',
+ code: value.code || value.name || 'ReaderError',
+ message: value.message || String(value),
+ recoverable: recoverable !== false
+ };
+ }
+
+ function reportError(error, stage, recoverable) {
+ const payload = errorPayload(error, stage, recoverable);
+ post('error', payload);
+ return payload;
+ }
+
+ function clamp(value, minimum, maximum) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return minimum;
+ return Math.min(maximum, Math.max(minimum, numeric));
+ }
+
+ function normalizePreferences(input, base) {
+ const source = input && typeof input === 'object' ? input : {};
+ const previous = base && typeof base === 'object' ? base : DEFAULT_PREFERENCES;
+ const theme = Object.prototype.hasOwnProperty.call(THEMES, source.theme)
+ ? source.theme
+ : (previous.theme || DEFAULT_PREFERENCES.theme);
+ const textAlign = ['justify', 'left', 'right', 'center'].indexOf(source.textAlign) >= 0
+ ? source.textAlign
+ : (previous.textAlign || DEFAULT_PREFERENCES.textAlign);
+ const pageTurnMode = ['tapSwipe', 'swipe', 'tap'].indexOf(source.pageTurnMode) >= 0
+ ? source.pageTurnMode
+ : (previous.pageTurnMode || DEFAULT_PREFERENCES.pageTurnMode);
+ const requestedFontFamily = typeof source.fontFamily === 'string' && source.fontFamily.trim()
+ ? source.fontFamily.trim()
+ : previous.fontFamily;
+ const bundledFontFamily = getBundledFontFamily(requestedFontFamily);
+ const fontFamily = bundledFontFamily
+ ? FONT_STACKS[bundledFontFamily]
+ : requestedFontFamily;
+
+ return {
+ fontFamily: fontFamily,
+ fontSize: clamp(source.fontSize === undefined ? previous.fontSize : source.fontSize, 12, 42),
+ lineHeight: clamp(source.lineHeight === undefined ? previous.lineHeight : source.lineHeight, 1.1, 2.4),
+ margin: clamp(source.margin === undefined ? previous.margin : source.margin, 8, 72),
+ textAlign: textAlign,
+ theme: theme,
+ verticalScroll: source.verticalScroll === undefined
+ ? Boolean(previous.verticalScroll)
+ : Boolean(source.verticalScroll),
+ pageTurnMode: pageTurnMode,
+ invertZones: source.invertZones === undefined
+ ? Boolean(previous.invertZones)
+ : Boolean(source.invertZones),
+ brightnessGesture: source.brightnessGesture === undefined
+ ? Boolean(previous.brightnessGesture)
+ : Boolean(source.brightnessGesture)
+ };
+ }
+
+ function applyShellPreferences(preferences) {
+ const root = document.documentElement;
+ const theme = THEMES[preferences.theme] || THEMES.light;
+ document.body.classList.remove('theme-light', 'theme-sepia', 'theme-dark');
+ document.body.classList.add('theme-' + preferences.theme);
+ root.style.setProperty('--reader-background', theme.background);
+ root.style.setProperty('--reader-text', theme.text);
+ root.style.setProperty('--reader-muted', theme.muted);
+ root.style.setProperty('--reader-font-family', preferences.fontFamily);
+ root.style.setProperty('--reader-font-size', preferences.fontSize + 'px');
+ root.style.setProperty('--reader-line-height', String(preferences.lineHeight));
+ root.style.setProperty('--reader-margin', preferences.margin + 'px');
+ root.style.setProperty('--reader-text-align', preferences.textAlign);
+ }
+
+ function inferFormat(payload, bytes) {
+ const format = String(payload && (payload.format || payload.mimeType || payload.mediaType) || '').toLowerCase();
+ const url = String(payload && payload.url || '').toLowerCase().split(/[?#]/)[0];
+ if (format.indexOf('fb2') >= 0 || /\.fb2$/.test(url)) return 'fb2';
+ if (format.indexOf('epub') >= 0 || /\.epub$/.test(url)) return 'epub';
+ if (bytes && bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b) return 'epub';
+ if (bytes) {
+ const prefix = new TextDecoder('utf-8').decode(bytes.slice(0, Math.min(bytes.length, 768)));
+ if (/<(?:\w+:)?FictionBook\b/i.test(prefix)) return 'fb2';
+ }
+ throw new Error('Не удалось определить формат книги. Поддерживаются EPUB и FB2.');
+ }
+
+ function flattenToc(items, depth, output) {
+ const result = output || [];
+ (items || []).forEach(function (item) {
+ const label = String(item && item.label || '').replace(/\s+/g, ' ').trim();
+ const href = String(item && item.href || '');
+ if (label || href) {
+ result.push({
+ label: label || href,
+ href: href,
+ depth: depth || 0
+ });
+ }
+ flattenToc(item && (item.subitems || item.children), (depth || 0) + 1, result);
+ });
+ return result;
+ }
+
+ function makeExcerpt(text, matchIndex, queryLength) {
+ const source = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!source) return '';
+ const start = Math.max(0, matchIndex - 54);
+ const end = Math.min(source.length, matchIndex + queryLength + 78);
+ return (start > 0 ? '…' : '') + source.slice(start, end) + (end < source.length ? '…' : '');
+ }
+
+ function normalizeHref(href) {
+ return String(href || '')
+ .split('#')[0]
+ .replace(/\\/g, '/')
+ .replace(/^(\.\.\/)+/, '')
+ .replace(/^\//, '');
+ }
+
+ function sameHref(left, right) {
+ const a = normalizeHref(left);
+ const b = normalizeHref(right);
+ return Boolean(a && b && (a === b || a.endsWith('/' + b) || b.endsWith('/' + a)));
+ }
+
+ function nextFrame() {
+ return new Promise(function (resolve) {
+ requestAnimationFrame(function () {
+ requestAnimationFrame(resolve);
+ });
+ });
+ }
+
+ function wait(milliseconds) {
+ return new Promise(function (resolve) { setTimeout(resolve, milliseconds); });
+ }
+
+ function stableLocatorKey(locator) {
+ if (typeof locator === 'string') return locator;
+ const value = locator || {};
+ return [
+ value.type || '',
+ value.cfi || '',
+ value.sectionId || '',
+ value.endSectionId || '',
+ value.offset || 0,
+ value.endOffset || value.offset || 0
+ ].join('|');
+ }
+
+ function getBundledFontFamily(value) {
+ const rawFamily = typeof value === 'string'
+ ? value
+ : String(value && value.fontFamily || '');
+ const primaryFamily = rawFamily
+ .split(',')[0]
+ .trim()
+ .replace(/^["']|["']$/g, '')
+ .replace(/\s+/g, ' ')
+ .toLowerCase();
+ for (let index = 0; index < BUNDLED_FONT_FAMILIES.length; index += 1) {
+ const family = BUNDLED_FONT_FAMILIES[index];
+ if (family.toLowerCase() === primaryFamily) return family;
+ }
+ return null;
+ }
+
+ function isBundledReaderFont(value) {
+ return Boolean(getBundledFontFamily(value));
+ }
+
+ function installReaderFontFaces(doc) {
+ if (!doc || !doc.head) return Promise.resolve(false);
+ let link = doc.getElementById(READER_FONT_STYLESHEET_ID);
+ if (link && link.dataset.readerV2FontState === 'ready') return Promise.resolve(true);
+ if (link && link.dataset.readerV2FontState === 'failed') return Promise.resolve(false);
+ if (link) {
+ try {
+ if (link.sheet) {
+ link.dataset.readerV2FontState = 'ready';
+ return Promise.resolve(true);
+ }
+ } catch (_) {
+ // A newly attached cross-origin sheet is confirmed by its load event below.
+ }
+ if (link.readerV2FontPromise) return link.readerV2FontPromise;
+ } else {
+ link = doc.createElement('link');
+ link.id = READER_FONT_STYLESHEET_ID;
+ link.rel = 'stylesheet';
+ link.href = READER_FONT_STYLESHEET_URL;
+ }
+
+ link.readerV2FontPromise = new Promise(function (resolve) {
+ let completed = false;
+ let timeoutId = null;
+ const finish = function (ready) {
+ if (completed) return;
+ completed = true;
+ if (timeoutId !== null) global.clearTimeout(timeoutId);
+ link.dataset.readerV2FontState = ready ? 'ready' : 'failed';
+ resolve(ready);
+ };
+ link.addEventListener('load', function () { finish(true); }, { once: true });
+ link.addEventListener('error', function () { finish(false); }, { once: true });
+ timeoutId = global.setTimeout(function () {
+ try { finish(Boolean(link.sheet)); }
+ catch (_) { finish(false); }
+ }, 8000);
+ if (!link.parentNode) doc.head.appendChild(link);
+ });
+ return link.readerV2FontPromise;
+ }
+
+ function waitForPreferredFont(doc, preferences) {
+ const family = getBundledFontFamily(preferences);
+ if (!family || !doc) return Promise.resolve(true);
+ return installReaderFontFaces(doc).then(function (stylesheetReady) {
+ if (!stylesheetReady) return false;
+ if (!doc.fonts || typeof doc.fonts.load !== 'function') return true;
+ const size = clamp(preferences && preferences.fontSize || 20, 12, 42);
+ const sample = 'АБВГДЕЁЖЗ абвгдеёжз 0123456789';
+ const requests = ['normal 400 ' + size + 'px "' + family + '"'];
+ if (family !== 'Droid Sans') {
+ requests.push('italic 400 ' + size + 'px "' + family + '"');
+ }
+ return Promise.all(requests.map(function (request) {
+ return doc.fonts.load(request, sample);
+ })).then(function (results) {
+ return results.every(function (faces) { return Boolean(faces && faces.length); });
+ });
+ }).catch(function () { return false; });
+ }
+
+ function validExternalUrl(rawUrl) {
+ const value = String(rawUrl || '').trim();
+ if (!/^(https?:|mailto:)/i.test(value)) return null;
+ try {
+ const url = new URL(value);
+ return /^(https?:|mailto:)$/.test(url.protocol) ? url.href : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function visibleSpeechText(documents, maxCharacters) {
+ const limit = clamp(Number(maxCharacters) || 3500, 200, 3500);
+ const blocks = [];
+ const seen = new Set();
+ (Array.isArray(documents) ? documents : [documents]).forEach(function (doc) {
+ if (!doc || !doc.body) return;
+ const view = doc.defaultView || global;
+ const width = Math.max(1, view.innerWidth || doc.documentElement.clientWidth || 1);
+ const height = Math.max(1, view.innerHeight || doc.documentElement.clientHeight || 1);
+ const candidates = doc.body.querySelectorAll('p,h1,h2,h3,h4,h5,h6,pre,blockquote,li,td,th');
+ Array.from(candidates).forEach(function (element) {
+ if (element.querySelector('p,h1,h2,h3,h4,h5,h6,pre,blockquote,li,td,th')) return;
+ const visible = Array.from(element.getClientRects()).some(function (rect) {
+ return rect.width > 0 && rect.height > 0 &&
+ rect.right > 0 && rect.bottom > 0 && rect.left < width && rect.top < height;
+ });
+ if (!visible) return;
+ const text = String(element.innerText || element.textContent || '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ if (!text || seen.has(text)) return;
+ seen.add(text);
+ blocks.push(text);
+ });
+ });
+ let text = blocks.join('\n').trim();
+ if (text.length <= limit) return text;
+ const candidate = text.slice(0, limit + 1);
+ const sentenceEnd = Math.max(
+ candidate.lastIndexOf('. '),
+ candidate.lastIndexOf('! '),
+ candidate.lastIndexOf('? '),
+ candidate.lastIndexOf('… ')
+ );
+ const wordEnd = candidate.lastIndexOf(' ', limit);
+ const cut = sentenceEnd >= Math.floor(limit * 0.55)
+ ? sentenceEnd + 1
+ : (wordEnd > 0 ? wordEnd : limit);
+ return candidate.slice(0, cut).trim();
+ }
+
+ Internal.DEFAULT_PREFERENCES = DEFAULT_PREFERENCES;
+ Internal.THEMES = THEMES;
+ Internal.READER_FONT_STYLESHEET_URL = READER_FONT_STYLESHEET_URL;
+ Internal.BUNDLED_FONT_FAMILIES = BUNDLED_FONT_FAMILIES;
+ Internal.post = post;
+ Internal.reportError = reportError;
+ Internal.clamp = clamp;
+ Internal.normalizePreferences = normalizePreferences;
+ Internal.applyShellPreferences = applyShellPreferences;
+ Internal.inferFormat = inferFormat;
+ Internal.flattenToc = flattenToc;
+ Internal.makeExcerpt = makeExcerpt;
+ Internal.sameHref = sameHref;
+ Internal.nextFrame = nextFrame;
+ Internal.wait = wait;
+ Internal.stableLocatorKey = stableLocatorKey;
+ Internal.getBundledFontFamily = getBundledFontFamily;
+ Internal.isBundledReaderFont = isBundledReaderFont;
+ Internal.installReaderFontFaces = installReaderFontFaces;
+ Internal.waitForPreferredFont = waitForPreferredFont;
+ Internal.validExternalUrl = validExternalUrl;
+ Internal.visibleSpeechText = visibleSpeechText;
+})(window);
diff --git a/app/src/main/assets/reader_v2/js/epub-engine.js b/app/src/main/assets/reader_v2/js/epub-engine.js
new file mode 100644
index 0000000..9d2e31d
--- /dev/null
+++ b/app/src/main/assets/reader_v2/js/epub-engine.js
@@ -0,0 +1,609 @@
+(function (global) {
+ 'use strict';
+
+ const Internal = global.ReaderV2Internal = global.ReaderV2Internal || {};
+
+ class EpubEngine {
+ constructor(mount, host) {
+ this.mount = mount;
+ this.host = host;
+ this.book = null;
+ this.rendition = null;
+ this.toc = [];
+ this.currentLocation = null;
+ this.currentCfi = null;
+ this.currentProgress = 0;
+ this.ready = false;
+ this.destroyed = false;
+ this.searchState = { query: '', results: [], currentIndex: -1, generation: 0, searching: false };
+ this.userHighlights = new Map();
+ this.searchHighlightCfi = null;
+ this.preparedDocuments = new WeakSet();
+ this.resizeTimer = null;
+ this.resizeObserver = null;
+ }
+
+ async load(arrayBuffer, payload) {
+ if (typeof global.ePub !== 'function') throw new Error('Модуль EPUB недоступен.');
+ if (typeof global.JSZip === 'undefined') throw new Error('Модуль ZIP недоступен.');
+
+ this.book = global.ePub(arrayBuffer);
+ await this.book.ready;
+ if (this.destroyed) throw new Error('Загрузка EPUB отменена.');
+
+ const navigation = await Promise.resolve(this.book.loaded && this.book.loaded.navigation)
+ .catch(function () { return null; });
+ const rawToc = navigation && navigation.toc
+ ? navigation.toc
+ : (this.book.navigation && this.book.navigation.toc || []);
+ this.toc = Internal.flattenToc(rawToc, 0, []);
+ Internal.post('toc', { format: 'epub', items: this.toc });
+
+ const initialTarget = this._targetFromPayload(payload);
+ let restoredLocator = false;
+ try {
+ await this._createRendition(initialTarget);
+ restoredLocator = Boolean(initialTarget);
+ } catch (error) {
+ if (!initialTarget) throw error;
+ console.warn('Не удалось восстановить сохранённую позицию EPUB; используется прогресс.', error);
+ await this._createRendition(undefined);
+ }
+ await this._prepareLocations(payload && payload.cachedLocations);
+ if (!restoredLocator && payload && Number.isFinite(Number(payload.progress))) {
+ await this.goToProgress(payload.progress);
+ await Internal.nextFrame();
+ }
+ if (this.destroyed) throw new Error('Загрузка EPUB отменена.');
+
+ this.ready = true;
+ this._observeResize();
+ this._emitCurrentProgress();
+ const metadata = await Promise.resolve(this.book.loaded && this.book.loaded.metadata)
+ .catch(function () { return {}; });
+
+ return {
+ format: 'epub',
+ title: metadata && metadata.title || payload.title || '',
+ author: metadata && (metadata.creator || metadata.author) || payload.author || '',
+ toc: this.toc,
+ locator: this.currentLocator(),
+ progress: this.currentProgress,
+ totalPages: this._totalLocations()
+ };
+ }
+
+ _targetFromPayload(payload) {
+ const locator = payload && payload.locator;
+ if (typeof locator === 'string' && locator.indexOf('epubcfi(') === 0) return locator;
+ if (locator && typeof locator.cfi === 'string' && locator.cfi.indexOf('epubcfi(') === 0) {
+ return locator.cfi;
+ }
+ return undefined;
+ }
+
+ async _createRendition(target) {
+ const preferences = this.host.getPreferences();
+ if (this.rendition) {
+ try { this.rendition.destroy(); } catch (_) {}
+ }
+ this.mount.replaceChildren();
+ this.rendition = this.book.renderTo(this.mount, {
+ width: '100%',
+ height: '100%',
+ spread: 'none',
+ manager: preferences.verticalScroll ? 'continuous' : 'default',
+ flow: preferences.verticalScroll ? 'scrolled-doc' : 'paginated'
+ });
+ this._attachRenditionEvents();
+ this._applyTheme(preferences);
+
+ await this.rendition.display(target);
+ await Internal.nextFrame();
+ const contents = this.rendition.getContents ? this.rendition.getContents() : [];
+ if (!contents || !contents.length) throw new Error('EPUB не создал отображаемую страницу.');
+ contents.forEach(this._prepareContents.bind(this));
+ await Promise.all(contents.map(function (item) {
+ return Internal.waitForPreferredFont(item.document, preferences);
+ }));
+ this._restoreAnnotations();
+ }
+
+ _attachRenditionEvents() {
+ const self = this;
+ this.rendition.on('rendered', function (_section, view) {
+ const contents = view && view.contents;
+ if (contents) self._prepareContents(contents);
+ });
+ this.rendition.on('relocated', function (location) {
+ self._onRelocated(location);
+ });
+ this.rendition.on('selected', function (cfiRange, contents) {
+ let text = '';
+ try { text = contents.window.getSelection().toString(); } catch (_) {}
+ text = String(text || '').trim();
+ if (!text) return;
+ Internal.post('selection', {
+ format: 'epub',
+ text: text,
+ locator: { type: 'epub', cfi: cfiRange },
+ chapter: self._chapterForHref(self.currentLocation && self.currentLocation.start && self.currentLocation.start.href)
+ });
+ });
+ }
+
+ _prepareContents(contents) {
+ if (!contents || !contents.document) return;
+ Internal.installReaderFontFaces(contents.document);
+ Internal.attachGestures(contents.document, {
+ getPreferences: this.host.getPreferences,
+ next: this.host.next,
+ previous: this.host.previous,
+ toggleControls: this.host.toggleControls
+ });
+ if (!this.preparedDocuments.has(contents.document)) {
+ this.preparedDocuments.add(contents.document);
+ contents.document.addEventListener('click', function (event) {
+ const anchor = event.target && event.target.closest && event.target.closest('a[href]');
+ if (!anchor) return;
+ const external = Internal.validExternalUrl(anchor.getAttribute('href'));
+ if (!external) return;
+ event.preventDefault();
+ event.stopPropagation();
+ Internal.post('externalLink', { url: external, format: 'epub' });
+ }, false);
+ }
+ this._applyTheme(this.host.getPreferences());
+ }
+
+ _epubStyles(preferences) {
+ const theme = Internal.THEMES[preferences.theme] || Internal.THEMES.light;
+ const alignment = preferences.textAlign + ' !important';
+ const lineHeight = preferences.lineHeight + ' !important';
+ const margin = preferences.margin + 'px !important';
+ return {
+ 'html': {
+ 'background': theme.background + ' !important',
+ 'color': theme.text + ' !important'
+ },
+ 'body': {
+ 'box-sizing': 'border-box !important',
+ 'min-height': '100% !important',
+ 'margin': '0 !important',
+ 'padding-top': '18px !important',
+ 'padding-right': margin,
+ 'padding-bottom': '18px !important',
+ 'padding-left': margin,
+ 'background': theme.background + ' !important',
+ 'color': theme.text + ' !important',
+ 'font-family': preferences.fontFamily + ' !important',
+ 'font-size': preferences.fontSize + 'px !important',
+ 'font-weight': '400 !important',
+ 'font-optical-sizing': 'auto !important',
+ 'line-height': lineHeight,
+ 'text-align': alignment,
+ 'hyphens': 'auto !important',
+ 'overflow-wrap': 'anywhere !important',
+ '-webkit-user-select': 'text !important',
+ 'user-select': 'text !important',
+ '-webkit-touch-callout': 'default !important',
+ 'touch-action': 'pan-y !important'
+ },
+ 'p': {
+ 'line-height': lineHeight,
+ 'text-align': alignment,
+ 'orphans': '2',
+ 'widows': '2'
+ },
+ 'h1, h2, h3, h4, h5, h6': {
+ 'color': theme.text + ' !important',
+ 'font-family': preferences.fontFamily + ' !important',
+ 'line-height': '1.28 !important',
+ 'text-align': 'center !important',
+ 'break-after': 'avoid !important'
+ },
+ 'img, svg': {
+ 'max-width': '100% !important',
+ 'max-height': '76vh !important',
+ 'object-fit': 'contain !important'
+ },
+ 'a': { 'color': '#e85b20 !important' },
+ 'pre, code': {
+ 'white-space': 'pre-wrap !important',
+ 'overflow-wrap': 'anywhere !important'
+ }
+ };
+ }
+
+ _applyTheme(preferences) {
+ if (!this.rendition || !this.rendition.themes) return;
+ const theme = Internal.THEMES[preferences.theme] || Internal.THEMES.light;
+ try {
+ this.rendition.themes.default(this._epubStyles(preferences));
+ this.rendition.themes.font(preferences.fontFamily);
+ this.rendition.themes.fontSize(preferences.fontSize + 'px');
+ } catch (_) {}
+ this.mount.style.backgroundColor = theme.background;
+ }
+
+ async applyPreferences(next, previous) {
+ if (!this.rendition || !this.book) return;
+ const target = this.currentCfi || undefined;
+ if (Boolean(next.verticalScroll) !== Boolean(previous && previous.verticalScroll)) {
+ await this._createRendition(target);
+ } else {
+ this._applyTheme(next);
+ try {
+ this.rendition.flow(next.verticalScroll ? 'scrolled-doc' : 'paginated');
+ this.rendition.resize();
+ if (target) await this.rendition.display(target);
+ } catch (error) {
+ Internal.reportError(error, 'epub.preferences', true);
+ }
+ }
+ await Internal.nextFrame();
+ const contents = this.rendition && this.rendition.getContents ? this.rendition.getContents() : [];
+ await Promise.all((contents || []).map(function (item) {
+ return Internal.waitForPreferredFont(item.document, next);
+ }));
+ this._emitCurrentProgress();
+ }
+
+ _observeResize() {
+ if (!global.ResizeObserver || this.resizeObserver) return;
+ const self = this;
+ this.resizeObserver = new ResizeObserver(function () {
+ clearTimeout(self.resizeTimer);
+ self.resizeTimer = setTimeout(async function () {
+ if (!self.rendition || self.destroyed) return;
+ const target = self.currentCfi;
+ try {
+ self.rendition.resize();
+ if (target) await self.rendition.display(target);
+ } catch (error) {
+ Internal.reportError(error, 'epub.resize', true);
+ }
+ }, 120);
+ });
+ this.resizeObserver.observe(this.mount);
+ }
+
+ async _prepareLocations(cachedLocations) {
+ if (!this.book || !this.book.locations || typeof this.book.locations.generate !== 'function') {
+ throw new Error('EPUB не поддерживает расчёт позиций.');
+ }
+ let cacheLoaded = false;
+ if (typeof cachedLocations === 'string' && cachedLocations.trim()) {
+ try {
+ this.book.locations.load(cachedLocations);
+ cacheLoaded = this._totalLocations() > 0;
+ } catch (_) {
+ cacheLoaded = false;
+ }
+ }
+ if (cacheLoaded) return;
+
+ await this.book.locations.generate(900);
+ if (this._totalLocations() <= 0) throw new Error('Не удалось рассчитать позиции EPUB.');
+ const saved = this.book.locations.save();
+ if (typeof saved === 'string' && saved) {
+ Internal.post('paginationCache', { locations: saved });
+ }
+ }
+
+ _totalLocations() {
+ try { return Math.max(0, Number(this.book.locations.length()) || 0); } catch (_) { return 0; }
+ }
+
+ _chapterForHref(href) {
+ const chapter = this.toc.find(function (item) { return Internal.sameHref(href, item.href); });
+ return chapter ? chapter.label : '';
+ }
+
+ _onRelocated(location) {
+ this.currentLocation = location || null;
+ this.currentCfi = location && location.start && location.start.cfi || this.currentCfi;
+ this._emitCurrentProgress();
+ }
+
+ _emitCurrentProgress() {
+ const location = this.currentLocation || {};
+ const start = location.start || {};
+ const end = location.end || {};
+ const total = this._totalLocations();
+ let index = -1;
+ let progress = Number(start.percentage);
+ if (this.currentCfi && total > 0) {
+ try {
+ index = this.book.locations.locationFromCfi(this.currentCfi);
+ progress = this.book.locations.percentageFromCfi(this.currentCfi);
+ } catch (_) {}
+ }
+ if (!Number.isFinite(progress)) progress = this.currentProgress || 0;
+ this.currentProgress = Internal.clamp(progress, 0, 1);
+ const displayed = start.displayed || {};
+ const chapterCurrentPage = Number(displayed.page) || null;
+ const chapterTotalPages = Number(displayed.total) || null;
+ Internal.post('progress', {
+ format: 'epub',
+ progress: this.currentProgress,
+ locator: this.currentLocator(),
+ href: start.href || '',
+ chapter: this._chapterForHref(start.href),
+ currentPage: index >= 0 ? index + 1 : null,
+ totalPages: total || null,
+ chapterPage: chapterCurrentPage,
+ chapterTotal: chapterTotalPages,
+ chapterCurrentPage: chapterCurrentPage,
+ chapterTotalPages: chapterTotalPages,
+ remainingInChapter: chapterTotalPages && chapterCurrentPage
+ ? Math.max(0, chapterTotalPages - chapterCurrentPage)
+ : null,
+ atStart: Boolean(location.atStart || start.atStart),
+ atEnd: Boolean(location.atEnd || end.atEnd)
+ });
+ }
+
+ currentLocator() {
+ return this.currentCfi ? { type: 'epub', cfi: this.currentCfi } : null;
+ }
+
+ async next() {
+ return this._navigate('next');
+ }
+
+ async previous() {
+ return this._navigate('previous');
+ }
+
+ async _navigate(direction) {
+ if (!this.rendition) return false;
+ const before = this.currentCfi;
+ try {
+ if (direction === 'next') await this.rendition.next();
+ else await this.rendition.prev();
+ await Internal.wait(45);
+ const handled = before !== this.currentCfi;
+ Internal.post('navigation', {
+ direction: direction,
+ handled: handled,
+ boundary: handled ? null : (direction === 'next' ? 'end' : 'start'),
+ locator: this.currentLocator()
+ });
+ return handled;
+ } catch (error) {
+ Internal.reportError(error, 'epub.navigation', true);
+ return false;
+ }
+ }
+
+ async goToProgress(value) {
+ const progress = Internal.clamp(value, 0, 1);
+ let target = null;
+ if (this._totalLocations() > 0) {
+ try { target = this.book.locations.cfiFromPercentage(progress); } catch (_) {}
+ }
+ if (!target) {
+ const spine = this.book.spine && (this.book.spine.spineItems || this.book.spine.items) || [];
+ const item = spine[Math.min(spine.length - 1, Math.floor(progress * spine.length))];
+ target = item && item.href;
+ }
+ if (target) await this.rendition.display(target);
+ }
+
+ async goToLocator(locator) {
+ const cfi = typeof locator === 'string' ? locator : locator && locator.cfi;
+ if (!cfi) return false;
+ await this.rendition.display(cfi);
+ return true;
+ }
+
+ async goToChapter(href) {
+ if (!href) return false;
+ await this.rendition.display(href);
+ return true;
+ }
+
+ async search(query) {
+ this.clearSearch(false);
+ const normalized = String(query || '').trim();
+ const generation = ++this.searchState.generation;
+ this.searchState.query = normalized;
+ this.searchState.searching = Boolean(normalized);
+ this._postSearch();
+ if (!normalized) return [];
+
+ const spine = this.book.spine && (this.book.spine.spineItems || this.book.spine.items) || [];
+ const results = [];
+ for (let index = 0; index < spine.length && results.length < 500; index += 1) {
+ if (generation !== this.searchState.generation) return [];
+ const section = spine[index];
+ try {
+ if (typeof section.load === 'function') await section.load(this.book.load.bind(this.book));
+ const matches = typeof section.find === 'function' ? section.find(normalized) : [];
+ matches.forEach(function (match) {
+ if (results.length >= 500) return;
+ results.push({
+ locator: { type: 'epub', cfi: match.cfi },
+ cfi: match.cfi,
+ excerpt: match.excerpt || normalized,
+ href: section.href || '',
+ chapter: this._chapterForHref(section.href || '')
+ });
+ }, this);
+ } catch (error) {
+ Internal.reportError(error, 'epub.search.section', true);
+ } finally {
+ try { if (typeof section.unload === 'function') section.unload(); } catch (_) {}
+ }
+ }
+ if (generation !== this.searchState.generation) return [];
+ this.searchState.results = results;
+ this.searchState.currentIndex = results.length ? 0 : -1;
+ this.searchState.searching = false;
+ if (results.length) await this._showSearchResult(0);
+ else this._postSearch();
+ return results;
+ }
+
+ async nextSearch() {
+ return this._showSearchResult(this.searchState.currentIndex + 1);
+ }
+
+ async previousSearch() {
+ return this._showSearchResult(this.searchState.currentIndex - 1);
+ }
+
+ async _showSearchResult(index) {
+ const results = this.searchState.results;
+ if (!results.length) {
+ this._postSearch();
+ return null;
+ }
+ const normalized = ((index % results.length) + results.length) % results.length;
+ this.searchState.currentIndex = normalized;
+ const result = results[normalized];
+ this._removeSearchAnnotation();
+ await this.rendition.display(result.cfi);
+ try {
+ this.rendition.annotations.highlight(
+ result.cfi,
+ { search: true },
+ null,
+ 'reader-search-current',
+ { fill: '#ef6c2f', 'fill-opacity': '.42' }
+ );
+ this.searchHighlightCfi = result.cfi;
+ } catch (_) {}
+ this._postSearch();
+ return result;
+ }
+
+ _postSearch() {
+ const current = this.searchState.currentIndex >= 0
+ ? this.searchState.results[this.searchState.currentIndex]
+ : null;
+ Internal.post('search', {
+ query: this.searchState.query,
+ total: this.searchState.results.length,
+ currentIndex: this.searchState.currentIndex,
+ searching: this.searchState.searching,
+ truncated: this.searchState.results.length >= 500,
+ result: current
+ });
+ }
+
+ _removeSearchAnnotation() {
+ if (!this.searchHighlightCfi || !this.rendition || !this.rendition.annotations) return;
+ try { this.rendition.annotations.remove(this.searchHighlightCfi, 'highlight'); } catch (_) {}
+ this.searchHighlightCfi = null;
+ }
+
+ clearSearch(emit) {
+ this._removeSearchAnnotation();
+ const generation = this.searchState.generation + 1;
+ this.searchState = { query: '', results: [], currentIndex: -1, generation: generation, searching: false };
+ if (emit !== false) this._postSearch();
+ }
+
+ clearSelection() {
+ if (!this.rendition || !this.rendition.getContents) return;
+ this.rendition.getContents().forEach(function (contents) {
+ try { contents.window.getSelection().removeAllRanges(); } catch (_) {}
+ });
+ }
+
+ addHighlight(locator, color) {
+ const cfi = typeof locator === 'string' ? locator : locator && locator.cfi;
+ if (!cfi || !this.rendition || !this.rendition.annotations) return false;
+ const normalizedColor = typeof color === 'string' && color.trim() ? color.trim() : '#ffd84a';
+ const key = Internal.stableLocatorKey({ type: 'epub', cfi: cfi });
+ this.removeHighlight({ type: 'epub', cfi: cfi });
+ try {
+ this.rendition.annotations.highlight(
+ cfi,
+ { color: normalizedColor },
+ null,
+ 'reader-user-highlight',
+ { fill: normalizedColor, 'fill-opacity': '.48' }
+ );
+ this.userHighlights.set(key, { locator: { type: 'epub', cfi: cfi }, color: normalizedColor });
+ return true;
+ } catch (error) {
+ Internal.reportError(error, 'epub.highlight.add', true);
+ return false;
+ }
+ }
+
+ removeHighlight(locator) {
+ const cfi = typeof locator === 'string' ? locator : locator && locator.cfi;
+ if (!cfi) return false;
+ const key = Internal.stableLocatorKey({ type: 'epub', cfi: cfi });
+ try { this.rendition.annotations.remove(cfi, 'highlight'); } catch (_) {}
+ return this.userHighlights.delete(key);
+ }
+
+ setHighlights(list) {
+ if (this.rendition && this.rendition.annotations) {
+ this.userHighlights.forEach(function (item) {
+ try { this.rendition.annotations.remove(item.locator.cfi, 'highlight'); } catch (_) {}
+ }, this);
+ }
+ this.userHighlights.clear();
+ (Array.isArray(list) ? list : []).forEach(function (item) {
+ if (item) this.addHighlight(item.locator || item, item.color);
+ }, this);
+ return this.userHighlights.size;
+ }
+
+ speechPage(maxCharacters) {
+ const contents = this.rendition && this.rendition.getContents
+ ? this.rendition.getContents()
+ : [];
+ return {
+ text: Internal.visibleSpeechText(contents.map(function (item) { return item.document; }), maxCharacters),
+ locator: this.currentLocator(),
+ canAdvance: this.currentProgress < .9999
+ };
+ }
+
+ _restoreAnnotations() {
+ const saved = Array.from(this.userHighlights.values());
+ this.userHighlights.clear();
+ saved.forEach(function (item) { this.addHighlight(item.locator, item.color); }, this);
+ }
+
+ state() {
+ return {
+ format: 'epub',
+ ready: this.ready,
+ locator: this.currentLocator(),
+ progress: this.currentProgress,
+ totalPages: this._totalLocations(),
+ tocCount: this.toc.length,
+ search: {
+ query: this.searchState.query,
+ total: this.searchState.results.length,
+ currentIndex: this.searchState.currentIndex,
+ searching: this.searchState.searching
+ },
+ highlightCount: this.userHighlights.size
+ };
+ }
+
+ destroy() {
+ this.destroyed = true;
+ clearTimeout(this.resizeTimer);
+ if (this.resizeObserver) this.resizeObserver.disconnect();
+ this.resizeObserver = null;
+ this.clearSearch(false);
+ try { if (this.rendition) this.rendition.destroy(); } catch (_) {}
+ try { if (this.book) this.book.destroy(); } catch (_) {}
+ this.rendition = null;
+ this.book = null;
+ this.mount.replaceChildren();
+ }
+ }
+
+ Internal.EpubEngine = EpubEngine;
+})(window);
diff --git a/app/src/main/assets/reader_v2/js/fb2-engine.js b/app/src/main/assets/reader_v2/js/fb2-engine.js
new file mode 100644
index 0000000..750a08a
--- /dev/null
+++ b/app/src/main/assets/reader_v2/js/fb2-engine.js
@@ -0,0 +1,1072 @@
+(function (global) {
+ 'use strict';
+
+ const Internal = global.ReaderV2Internal = global.ReaderV2Internal || {};
+
+ function localName(node) {
+ return String(node && (node.localName || node.nodeName) || '').toLowerCase().replace(/^.*:/, '');
+ }
+
+ function elementChildren(node) {
+ return Array.prototype.filter.call(node && node.childNodes || [], function (child) {
+ return child.nodeType === Node.ELEMENT_NODE;
+ });
+ }
+
+ function childrenNamed(node, name) {
+ return elementChildren(node).filter(function (child) { return localName(child) === name; });
+ }
+
+ function firstNamed(node, name) {
+ const all = node && node.getElementsByTagNameNS
+ ? node.getElementsByTagNameNS('*', name)
+ : [];
+ return all && all.length ? all[0] : null;
+ }
+
+ function directChild(node, name) {
+ return elementChildren(node).find(function (child) { return localName(child) === name; }) || null;
+ }
+
+ function normalizedText(node) {
+ return String(node && node.textContent || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function hrefAttribute(node) {
+ if (!node || !node.getAttribute) return '';
+ return node.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ||
+ node.getAttribute('xlink:href') || node.getAttribute('l:href') || node.getAttribute('href') || '';
+ }
+
+ function xmlId(node) {
+ if (!node || !node.getAttribute) return '';
+ return node.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'id') ||
+ node.getAttribute('xml:id') || node.getAttribute('id') || '';
+ }
+
+ function safeId(value, fallback) {
+ const raw = String(value || '').trim();
+ if (!raw) return fallback;
+ const encoded = encodeURIComponent(raw).replace(/%/g, '_').replace(/[^A-Za-z0-9_.~-]/g, '-');
+ return 'fb2-id-' + encoded;
+ }
+
+ function decodeXml(arrayBuffer) {
+ const bytes = new Uint8Array(arrayBuffer);
+ let encoding = 'utf-8';
+ let offset = 0;
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
+ encoding = 'utf-16le';
+ offset = 2;
+ } else if (bytes[0] === 0xfe && bytes[1] === 0xff) {
+ encoding = 'utf-16be';
+ offset = 2;
+ } else if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
+ offset = 3;
+ } else {
+ let ascii = '';
+ const length = Math.min(512, bytes.length);
+ for (let index = 0; index < length; index += 1) ascii += String.fromCharCode(bytes[index]);
+ const declared = ascii.match(/<\?xml[^>]*encoding\s*=\s*["']([^"']+)["']/i);
+ if (declared) encoding = declared[1].trim().toLowerCase();
+ }
+ try {
+ return new TextDecoder(encoding).decode(bytes.subarray(offset));
+ } catch (_) {
+ return new TextDecoder('utf-8').decode(bytes.subarray(offset));
+ }
+ }
+
+ function parseXml(arrayBuffer) {
+ const xml = decodeXml(arrayBuffer);
+ const doc = new DOMParser().parseFromString(xml, 'application/xml');
+ const parserErrors = doc.getElementsByTagName('parsererror');
+ if (parserErrors && parserErrors.length) throw new Error('FB2 содержит некорректный XML.');
+ if (!doc.documentElement || localName(doc.documentElement) !== 'fictionbook') {
+ throw new Error('Файл не является документом FictionBook.');
+ }
+ return doc;
+ }
+
+ function textPoint(section, offset) {
+ const targetOffset = Math.max(0, Number(offset) || 0);
+ const walker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT, null);
+ let node;
+ let consumed = 0;
+ while ((node = walker.nextNode())) {
+ const length = node.nodeValue.length;
+ if (targetOffset <= consumed + length) {
+ return { node: node, offset: Math.max(0, Math.min(length, targetOffset - consumed)) };
+ }
+ consumed += length;
+ }
+ return { node: section, offset: section.childNodes.length };
+ }
+
+ function offsetWithin(section, node, offset) {
+ try {
+ const range = document.createRange();
+ range.selectNodeContents(section);
+ range.setEnd(node, offset);
+ return range.toString().length;
+ } catch (_) {
+ return 0;
+ }
+ }
+
+ function closestSection(node, root) {
+ const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
+ const section = element && element.closest && element.closest('.fb2-section');
+ return section && root.contains(section) ? section : null;
+ }
+
+ function unwrap(element) {
+ const parent = element && element.parentNode;
+ if (!parent) return;
+ while (element.firstChild) parent.insertBefore(element.firstChild, element);
+ parent.removeChild(element);
+ parent.normalize();
+ }
+
+ class Fb2Engine {
+ constructor(mount, host) {
+ this.mount = mount;
+ this.host = host;
+ this.documentElement = mount.querySelector('#fb2-document');
+ this.toc = [];
+ this.chapterLabels = new Map();
+ this.binaryUrls = new Map();
+ this.currentPage = 0;
+ this.totalPages = 1;
+ this.currentProgress = 0;
+ this.ready = false;
+ this.destroyed = false;
+ this.searchState = { query: '', results: [], currentIndex: -1, generation: 0, searching: false };
+ this.userHighlights = new Map();
+ this.searchMarks = [];
+ this.resizeObserver = null;
+ this.resizeTimer = null;
+ this.scrollFrame = 0;
+ this.selectionTimer = null;
+ this.detachGestures = null;
+ this.sectionCounter = 0;
+ this.usedSectionIds = new Set();
+ this._onClick = this._handleClick.bind(this);
+ this._onScroll = this._handleScroll.bind(this);
+ this._onSelectionChange = this._handleSelectionChange.bind(this);
+ this.mount.addEventListener('click', this._onClick, false);
+ this.mount.addEventListener('scroll', this._onScroll, { passive: true });
+ document.addEventListener('selectionchange', this._onSelectionChange, false);
+ }
+
+ async load(arrayBuffer, payload) {
+ const xmlDocument = parseXml(arrayBuffer);
+ this._loadBinaries(xmlDocument);
+ this._renderDocument(xmlDocument);
+ await this._waitForImages();
+ Internal.post('toc', { format: 'fb2', items: this.toc });
+ this.detachGestures = Internal.attachGestures(document, {
+ getPreferences: this.host.getPreferences,
+ next: this.host.next,
+ previous: this.host.previous,
+ toggleControls: this.host.toggleControls
+ });
+ await Internal.waitForPreferredFont(document, this.host.getPreferences());
+ await this._layout(false);
+
+ if (payload && payload.locator) await this.goToLocator(payload.locator);
+ else if (payload && Number.isFinite(Number(payload.progress))) await this.goToProgress(payload.progress);
+ await Internal.nextFrame();
+ if (!this.documentElement.childNodes.length) throw new Error('FB2 не содержит отображаемого текста.');
+
+ this.ready = true;
+ this._observeResize();
+ this._emitProgress();
+ const metadata = this._metadata(xmlDocument);
+ return {
+ format: 'fb2',
+ title: metadata.title || payload.title || '',
+ author: metadata.author || payload.author || '',
+ toc: this.toc,
+ locator: this.currentLocator(),
+ progress: this.currentProgress,
+ totalPages: this.totalPages
+ };
+ }
+
+ _metadata(xmlDocument) {
+ const title = normalizedText(firstNamed(xmlDocument, 'book-title'));
+ const titleInfo = firstNamed(xmlDocument, 'title-info');
+ const authorElement = titleInfo && firstNamed(titleInfo, 'author');
+ const names = ['first-name', 'middle-name', 'last-name'].map(function (name) {
+ return normalizedText(authorElement && firstNamed(authorElement, name));
+ }).filter(Boolean);
+ return { title: title, author: names.join(' ') };
+ }
+
+ _loadBinaries(xmlDocument) {
+ const binaries = xmlDocument.getElementsByTagNameNS('*', 'binary');
+ Array.prototype.forEach.call(binaries || [], function (binary) {
+ const id = xmlId(binary);
+ if (!id) return;
+ const encoded = String(binary.textContent || '').replace(/\s+/g, '');
+ if (!encoded) return;
+ try {
+ const raw = global.atob(encoded);
+ const bytes = new Uint8Array(raw.length);
+ for (let index = 0; index < raw.length; index += 1) bytes[index] = raw.charCodeAt(index);
+ const type = binary.getAttribute('content-type') || 'application/octet-stream';
+ this.binaryUrls.set(id, URL.createObjectURL(new Blob([bytes], { type: type })));
+ } catch (_) {
+ // A broken optional image must not make the whole book unreadable.
+ }
+ }, this);
+ }
+
+ _renderDocument(xmlDocument) {
+ this.documentElement.replaceChildren();
+ this.toc = [];
+ this.chapterLabels.clear();
+ this.sectionCounter = 0;
+ this.usedSectionIds.clear();
+ const fragment = document.createDocumentFragment();
+ const bodies = xmlDocument.getElementsByTagNameNS('*', 'body');
+ Array.prototype.forEach.call(bodies || [], function (body, bodyIndex) {
+ const bodyContainer = document.createElement('div');
+ bodyContainer.className = 'fb2-body fb2-section';
+ bodyContainer.id = 'fb2-body-' + (bodyIndex + 1);
+ bodyContainer.dataset.sectionId = bodyContainer.id;
+ this.usedSectionIds.add(bodyContainer.id);
+ const bodyName = body.getAttribute('name');
+ if (bodyName) {
+ bodyContainer.dataset.bodyName = bodyName;
+ this.chapterLabels.set(bodyContainer.id, bodyName);
+ }
+ elementChildren(body).forEach(function (child) {
+ const rendered = localName(child) === 'section'
+ ? this._renderSection(child, 0)
+ : this._renderBlock(child, 0, 'fb2-body-' + bodyIndex);
+ if (rendered) bodyContainer.appendChild(rendered);
+ }, this);
+ if (bodyContainer.childNodes.length) fragment.appendChild(bodyContainer);
+ }, this);
+ this.documentElement.appendChild(fragment);
+ }
+
+ _renderSection(source, depth) {
+ this.sectionCounter += 1;
+ const baseId = safeId(xmlId(source), 'fb2-section-' + this.sectionCounter);
+ let id = baseId;
+ let suffix = 2;
+ while (this.usedSectionIds.has(id)) {
+ id = baseId + '-' + suffix;
+ suffix += 1;
+ }
+ this.usedSectionIds.add(id);
+ const section = document.createElement('section');
+ section.className = 'fb2-section';
+ section.id = id;
+ section.dataset.sectionId = id;
+ section.dataset.depth = String(depth);
+ const titleSource = directChild(source, 'title');
+ const label = normalizedText(titleSource);
+ if (label) {
+ this.toc.push({ label: label, href: id, depth: depth });
+ this.chapterLabels.set(id, label);
+ }
+ elementChildren(source).forEach(function (child) {
+ const rendered = localName(child) === 'section'
+ ? this._renderSection(child, depth + 1)
+ : this._renderBlock(child, depth, id);
+ if (rendered) section.appendChild(rendered);
+ }, this);
+ return section;
+ }
+
+ _renderBlock(source, depth, sectionId) {
+ const tag = localName(source);
+ if (tag === 'title') return this._renderTitle(source, depth);
+ if (tag === 'p') return this._paragraph(source);
+ if (tag === 'subtitle') {
+ const p = this._paragraph(source);
+ p.className = 'fb2-subtitle';
+ return p;
+ }
+ if (tag === 'empty-line') {
+ const spacer = document.createElement('div');
+ spacer.className = 'fb2-empty-line';
+ spacer.setAttribute('aria-hidden', 'true');
+ spacer.style.height = '.72em';
+ return spacer;
+ }
+ if (tag === 'epigraph' || tag === 'cite' || tag === 'annotation') {
+ const block = document.createElement(tag === 'annotation' ? 'aside' : 'blockquote');
+ block.className = tag === 'epigraph' ? 'fb2-epigraph' : (tag === 'cite' ? 'fb2-cite' : 'fb2-annotation');
+ this._appendBlockChildren(block, source, depth, sectionId);
+ return block;
+ }
+ if (tag === 'poem') return this._renderPoem(source, depth, sectionId);
+ if (tag === 'image') return this._renderImage(source);
+ if (tag === 'table') return this._renderTable(source);
+ if (tag === 'code') {
+ const pre = document.createElement('pre');
+ pre.className = 'fb2-code';
+ pre.textContent = source.textContent || '';
+ return pre;
+ }
+ if (tag === 'text-author' || tag === 'date') {
+ const p = this._paragraph(source);
+ p.className = tag === 'text-author' ? 'fb2-text-author' : 'fb2-date';
+ return p;
+ }
+ if (tag === 'section') return this._renderSection(source, depth + 1);
+ if (elementChildren(source).length) {
+ const div = document.createElement('div');
+ div.className = 'fb2-' + tag.replace(/[^a-z0-9_-]/g, '');
+ this._appendBlockChildren(div, source, depth, sectionId);
+ return div;
+ }
+ const text = normalizedText(source);
+ if (!text) return null;
+ const p = document.createElement('p');
+ p.textContent = text;
+ return p;
+ }
+
+ _appendBlockChildren(target, source, depth, sectionId) {
+ Array.prototype.forEach.call(source.childNodes || [], function (child) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ if (String(child.nodeValue || '').trim()) target.appendChild(document.createTextNode(child.nodeValue));
+ return;
+ }
+ if (child.nodeType !== Node.ELEMENT_NODE) return;
+ const rendered = this._renderBlock(child, depth, sectionId);
+ if (rendered) target.appendChild(rendered);
+ }, this);
+ }
+
+ _renderTitle(source, depth) {
+ const heading = document.createElement('h' + Math.min(6, Math.max(2, depth + 2)));
+ heading.className = 'fb2-title';
+ const lines = childrenNamed(source, 'p');
+ if (!lines.length) {
+ heading.textContent = normalizedText(source);
+ } else {
+ lines.forEach(function (line, index) {
+ if (index) heading.appendChild(document.createElement('br'));
+ this._appendInline(heading, line);
+ }, this);
+ }
+ return heading;
+ }
+
+ _paragraph(source) {
+ const p = document.createElement('p');
+ this._appendInline(p, source);
+ return p;
+ }
+
+ _appendInline(target, source) {
+ Array.prototype.forEach.call(source.childNodes || [], function (node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ target.appendChild(document.createTextNode(node.nodeValue || ''));
+ return;
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
+ const tag = localName(node);
+ if (tag === 'image') {
+ const image = this._renderImage(node);
+ if (image) target.appendChild(image);
+ return;
+ }
+ if (tag === 'empty-line') {
+ target.appendChild(document.createElement('br'));
+ return;
+ }
+ const tagMap = {
+ strong: 'strong', bold: 'strong', emphasis: 'em', em: 'em',
+ strikethrough: 's', sub: 'sub', sup: 'sup', code: 'code', style: 'span'
+ };
+ if (tag === 'a') {
+ const anchor = document.createElement('a');
+ const href = hrefAttribute(node).trim();
+ if (href.charAt(0) === '#') {
+ anchor.href = href;
+ anchor.dataset.internalHref = safeId(href.slice(1), href.slice(1));
+ } else {
+ const external = Internal.validExternalUrl(href);
+ if (external) {
+ anchor.href = external;
+ anchor.dataset.externalUrl = external;
+ }
+ }
+ this._appendInline(anchor, node);
+ target.appendChild(anchor);
+ return;
+ }
+ const element = document.createElement(tagMap[tag] || 'span');
+ if (tag === 'style') {
+ element.className = 'fb2-style';
+ const styleName = node.getAttribute('name');
+ if (styleName) element.dataset.styleName = styleName;
+ }
+ this._appendInline(element, node);
+ target.appendChild(element);
+ }, this);
+ }
+
+ _renderImage(source) {
+ const href = hrefAttribute(source).replace(/^#/, '');
+ const url = this.binaryUrls.get(href);
+ if (!url) return null;
+ const image = document.createElement('img');
+ image.className = 'fb2-image';
+ image.src = url;
+ image.alt = source.getAttribute('title') || 'Иллюстрация';
+ image.loading = 'eager';
+ image.decoding = 'async';
+ return image;
+ }
+
+ async _waitForImages() {
+ const images = Array.from(this.documentElement.querySelectorAll('img'));
+ await Promise.all(images.map(function (image) {
+ if (image.complete) return Promise.resolve();
+ if (typeof image.decode === 'function') return image.decode().catch(function () {});
+ return new Promise(function (resolve) {
+ image.addEventListener('load', resolve, { once: true });
+ image.addEventListener('error', resolve, { once: true });
+ });
+ }));
+ }
+
+ _renderPoem(source, depth, sectionId) {
+ const poem = document.createElement('div');
+ poem.className = 'fb2-poem';
+ elementChildren(source).forEach(function (child) {
+ const tag = localName(child);
+ if (tag === 'stanza') {
+ const stanza = document.createElement('div');
+ stanza.className = 'fb2-stanza';
+ elementChildren(child).forEach(function (line) {
+ const lineTag = localName(line);
+ if (lineTag === 'v') stanza.appendChild(this._paragraph(line));
+ else {
+ const rendered = this._renderBlock(line, depth, sectionId);
+ if (rendered) stanza.appendChild(rendered);
+ }
+ }, this);
+ poem.appendChild(stanza);
+ } else {
+ const rendered = this._renderBlock(child, depth, sectionId);
+ if (rendered) poem.appendChild(rendered);
+ }
+ }, this);
+ return poem;
+ }
+
+ _renderTable(source) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'fb2-table-wrap';
+ const table = document.createElement('table');
+ childrenNamed(source, 'tr').forEach(function (rowSource) {
+ const row = document.createElement('tr');
+ elementChildren(rowSource).forEach(function (cellSource) {
+ const tag = localName(cellSource) === 'th' ? 'th' : 'td';
+ const cell = document.createElement(tag);
+ const colspan = Number(cellSource.getAttribute('colspan'));
+ const rowspan = Number(cellSource.getAttribute('rowspan'));
+ if (Number.isInteger(colspan) && colspan > 1 && colspan <= 50) cell.colSpan = colspan;
+ if (Number.isInteger(rowspan) && rowspan > 1 && rowspan <= 200) cell.rowSpan = rowspan;
+ this._appendInline(cell, cellSource);
+ row.appendChild(cell);
+ }, this);
+ table.appendChild(row);
+ }, this);
+ wrapper.appendChild(table);
+ return wrapper;
+ }
+
+ async _layout(preserve) {
+ const preferences = this.host.getPreferences();
+ const locator = preserve ? this.currentLocator() : null;
+ const progress = this.currentProgress;
+ const width = Math.max(1, this.mount.clientWidth);
+ const height = Math.max(1, this.mount.clientHeight);
+ const margin = Internal.clamp(preferences.margin, 8, Math.max(8, width * .28));
+ const blockPadding = 18;
+
+ this.mount.classList.toggle('is-scrolled', preferences.verticalScroll);
+ this.mount.classList.toggle('is-paginated', !preferences.verticalScroll);
+ this.documentElement.style.transform = 'none';
+ this.documentElement.style.columnWidth = 'auto';
+ this.documentElement.style.columnGap = 'normal';
+ this.documentElement.style.columnFill = 'auto';
+ this.documentElement.style.width = '';
+ this.documentElement.style.height = '';
+ this.documentElement.style.margin = blockPadding + 'px ' + margin + 'px';
+ await Internal.nextFrame();
+
+ if (preferences.verticalScroll) {
+ this.totalPages = Math.max(1, Math.ceil(this.mount.scrollHeight / height));
+ } else {
+ const contentWidth = Math.max(1, width - margin * 2);
+ const contentHeight = Math.max(1, height - blockPadding * 2);
+ this.documentElement.style.width = contentWidth + 'px';
+ this.documentElement.style.height = contentHeight + 'px';
+ this.documentElement.style.columnWidth = contentWidth + 'px';
+ this.documentElement.style.columnGap = margin * 2 + 'px';
+ await Internal.nextFrame();
+ this.totalPages = Math.max(1, Math.ceil((this.documentElement.scrollWidth + margin * 2 - 1) / width));
+ }
+
+ if (locator) await this.goToLocator(locator);
+ else await this.goToProgress(progress);
+ this._emitProgress();
+ }
+
+ async applyPreferences(next, _previous) {
+ await Internal.waitForPreferredFont(document, next);
+ await this._layout(true);
+ }
+
+ _observeResize() {
+ if (!global.ResizeObserver || this.resizeObserver) return;
+ const self = this;
+ this.resizeObserver = new ResizeObserver(function () {
+ clearTimeout(self.resizeTimer);
+ self.resizeTimer = setTimeout(function () {
+ if (!self.destroyed) self._layout(true).catch(function (error) {
+ Internal.reportError(error, 'fb2.resize', true);
+ });
+ }, 120);
+ });
+ this.resizeObserver.observe(this.mount);
+ }
+
+ _handleClick(event) {
+ const anchor = event.target && event.target.closest && event.target.closest('a');
+ if (!anchor || !this.documentElement.contains(anchor)) return;
+ const external = anchor.dataset.externalUrl;
+ if (external) {
+ event.preventDefault();
+ event.stopPropagation();
+ Internal.post('externalLink', { url: external, format: 'fb2' });
+ return;
+ }
+ const internal = anchor.dataset.internalHref;
+ if (internal) {
+ event.preventDefault();
+ this.goToChapter(internal);
+ }
+ }
+
+ _handleScroll() {
+ if (!this.host.getPreferences().verticalScroll || this.scrollFrame) return;
+ const self = this;
+ this.scrollFrame = requestAnimationFrame(function () {
+ self.scrollFrame = 0;
+ self._emitProgress();
+ });
+ }
+
+ _handleSelectionChange() {
+ clearTimeout(this.selectionTimer);
+ const self = this;
+ this.selectionTimer = setTimeout(function () { self._emitSelection(); }, 160);
+ }
+
+ _emitSelection() {
+ let selection;
+ try { selection = global.getSelection(); } catch (_) { return; }
+ if (!selection || selection.isCollapsed || !selection.rangeCount) return;
+ const text = String(selection.toString() || '').trim();
+ if (!text) return;
+ const range = selection.getRangeAt(0);
+ const startSection = closestSection(range.startContainer, this.documentElement);
+ if (!startSection) return;
+ const endSection = closestSection(range.endContainer, this.documentElement) || startSection;
+ const startOffset = offsetWithin(startSection, range.startContainer, range.startOffset);
+ const endOffset = endSection === startSection
+ ? offsetWithin(startSection, range.endContainer, range.endOffset)
+ : startSection.textContent.length;
+ Internal.post('selection', {
+ format: 'fb2',
+ text: text,
+ locator: {
+ type: 'fb2',
+ sectionId: startSection.id,
+ offset: startOffset,
+ endSectionId: endSection.id,
+ endOffset: Math.max(startOffset, endOffset)
+ },
+ chapter: this.chapterLabels.get(startSection.id) || this._chapterForSection(startSection)
+ });
+ }
+
+ _caretAt(x, y) {
+ try {
+ if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
+ if (document.caretPositionFromPoint) {
+ const point = document.caretPositionFromPoint(x, y);
+ if (!point) return null;
+ const range = document.createRange();
+ range.setStart(point.offsetNode, point.offset);
+ range.collapse(true);
+ return range;
+ }
+ } catch (_) {}
+ return null;
+ }
+
+ currentLocator() {
+ if (!this.documentElement || !this.documentElement.childNodes.length) return null;
+ const bounds = this.mount.getBoundingClientRect();
+ const margin = this.host.getPreferences().margin;
+ const range = this._caretAt(bounds.left + Math.max(10, margin + 2), bounds.top + 24);
+ const section = range && closestSection(range.startContainer, this.documentElement);
+ if (section) {
+ return {
+ type: 'fb2',
+ sectionId: section.id,
+ offset: offsetWithin(section, range.startContainer, range.startOffset)
+ };
+ }
+ const sections = Array.from(this.documentElement.querySelectorAll('.fb2-section'));
+ const visible = sections.find(function (item) {
+ const rect = item.getBoundingClientRect();
+ return rect.right > bounds.left && rect.left < bounds.right && rect.bottom > bounds.top && rect.top < bounds.bottom;
+ }) || sections[0];
+ return visible ? { type: 'fb2', sectionId: visible.id, offset: 0 } : null;
+ }
+
+ _chapterForSection(section) {
+ const chapter = this._chapterElementForSection(section);
+ return chapter ? (this.chapterLabels.get(chapter.id) || '') : '';
+ }
+
+ _chapterElementForSection(section) {
+ let current = section;
+ while (current && current !== this.documentElement) {
+ if (this.chapterLabels.has(current.id)) return current;
+ current = current.parentElement && current.parentElement.closest('.fb2-section');
+ }
+ return null;
+ }
+
+ _chapterMetrics(section) {
+ const chapter = this._chapterElementForSection(section);
+ if (!chapter) {
+ const current = Math.max(1, Math.min(this.totalPages, this.currentPage + 1));
+ return {
+ current: current,
+ total: Math.max(1, this.totalPages),
+ remaining: Math.max(0, this.totalPages - current)
+ };
+ }
+
+ if (this.host.getPreferences().verticalScroll) {
+ const viewportHeight = Math.max(1, this.mount.clientHeight);
+ const mountRect = this.mount.getBoundingClientRect();
+ const rect = chapter.getBoundingClientRect();
+ const chapterTop = rect.top - mountRect.top + this.mount.scrollTop;
+ const chapterHeight = Math.max(1, rect.height);
+ const chapterTotal = Math.max(1, Math.ceil(chapterHeight / viewportHeight));
+ const offset = Internal.clamp(
+ this.mount.scrollTop - chapterTop,
+ 0,
+ Math.max(0, chapterHeight - 1)
+ );
+ const chapterCurrent = Math.max(
+ 1,
+ Math.min(chapterTotal, Math.floor(offset / viewportHeight) + 1)
+ );
+ return {
+ current: chapterCurrent,
+ total: chapterTotal,
+ remaining: Math.max(0, chapterTotal - chapterCurrent)
+ };
+ }
+
+ const pageWidth = Math.max(1, this.mount.clientWidth);
+ const mountLeft = this.mount.getBoundingClientRect().left;
+ const translation = this.currentPage * pageWidth;
+ const rects = Array.from(chapter.getClientRects()).filter(function (rect) {
+ return rect.width > 0 && rect.height > 0;
+ });
+ let firstPage = Number.POSITIVE_INFINITY;
+ let lastPage = Number.NEGATIVE_INFINITY;
+ rects.forEach(function (rect) {
+ const absoluteLeft = rect.left - mountLeft + translation;
+ const absoluteRight = rect.right - mountLeft + translation;
+ firstPage = Math.min(firstPage, Math.floor(Math.max(0, absoluteLeft) / pageWidth));
+ lastPage = Math.max(lastPage, Math.floor(Math.max(0, absoluteRight - 1) / pageWidth));
+ });
+ if (!Number.isFinite(firstPage) || !Number.isFinite(lastPage)) {
+ firstPage = this.currentPage;
+ lastPage = this.currentPage;
+ }
+ firstPage = Math.max(0, Math.min(this.totalPages - 1, firstPage));
+ lastPage = Math.max(firstPage, Math.min(this.totalPages - 1, lastPage));
+ const chapterTotal = Math.max(1, lastPage - firstPage + 1);
+ const chapterCurrent = Math.max(
+ 1,
+ Math.min(chapterTotal, this.currentPage - firstPage + 1)
+ );
+ return {
+ current: chapterCurrent,
+ total: chapterTotal,
+ remaining: Math.max(0, chapterTotal - chapterCurrent)
+ };
+ }
+
+ _emitProgress() {
+ const preferences = this.host.getPreferences();
+ let progress;
+ if (preferences.verticalScroll) {
+ const maximum = Math.max(0, this.mount.scrollHeight - this.mount.clientHeight);
+ progress = maximum > 0 ? this.mount.scrollTop / maximum : 0;
+ this.currentPage = Math.min(this.totalPages - 1, Math.floor(progress * this.totalPages));
+ } else {
+ progress = this.totalPages > 1 ? this.currentPage / (this.totalPages - 1) : 0;
+ }
+ this.currentProgress = Internal.clamp(progress, 0, 1);
+ const locator = this.currentLocator();
+ const section = locator && document.getElementById(locator.sectionId);
+ const chapterMetrics = this._chapterMetrics(section);
+ Internal.post('progress', {
+ format: 'fb2',
+ progress: this.currentProgress,
+ locator: locator,
+ chapter: section ? this._chapterForSection(section) : '',
+ currentPage: this.currentPage + 1,
+ totalPages: this.totalPages,
+ chapterPage: chapterMetrics.current,
+ chapterTotal: chapterMetrics.total,
+ chapterCurrentPage: chapterMetrics.current,
+ chapterTotalPages: chapterMetrics.total,
+ remainingInChapter: chapterMetrics.remaining,
+ atStart: this.currentProgress <= .0001,
+ atEnd: this.currentProgress >= .9999
+ });
+ }
+
+ async next() {
+ return this._navigate('next');
+ }
+
+ async previous() {
+ return this._navigate('previous');
+ }
+
+ async _navigate(direction) {
+ const vertical = this.host.getPreferences().verticalScroll;
+ let handled = false;
+ if (vertical) {
+ const maximum = Math.max(0, this.mount.scrollHeight - this.mount.clientHeight);
+ const before = this.mount.scrollTop;
+ const delta = this.mount.clientHeight * .88 * (direction === 'next' ? 1 : -1);
+ const target = Internal.clamp(before + delta, 0, maximum);
+ this.mount.scrollTo({ top: target, behavior: 'smooth' });
+ handled = Math.abs(target - before) > 1;
+ await Internal.wait(190);
+ } else {
+ const targetPage = Internal.clamp(this.currentPage + (direction === 'next' ? 1 : -1), 0, this.totalPages - 1);
+ handled = targetPage !== this.currentPage;
+ this._showPage(targetPage);
+ }
+ this._emitProgress();
+ Internal.post('navigation', {
+ direction: direction,
+ handled: handled,
+ boundary: handled ? null : (direction === 'next' ? 'end' : 'start'),
+ locator: this.currentLocator()
+ });
+ return handled;
+ }
+
+ _showPage(page) {
+ this.currentPage = Math.round(Internal.clamp(page, 0, this.totalPages - 1));
+ this.documentElement.style.transform = 'translate3d(' + (-this.currentPage * this.mount.clientWidth) + 'px,0,0)';
+ }
+
+ async goToProgress(value) {
+ const progress = Internal.clamp(value, 0, 1);
+ if (this.host.getPreferences().verticalScroll) {
+ const maximum = Math.max(0, this.mount.scrollHeight - this.mount.clientHeight);
+ this.mount.scrollTop = Math.round(progress * maximum);
+ } else {
+ this._showPage(Math.round(progress * (this.totalPages - 1)));
+ }
+ this._emitProgress();
+ return true;
+ }
+
+ _normalizeLocator(locator) {
+ if (locator && typeof locator === 'object') return locator;
+ const raw = String(locator || '');
+ const match = raw.match(/^fb2:([^:]+):(\d+)(?::(\d+))?$/);
+ return match ? {
+ type: 'fb2',
+ sectionId: decodeURIComponent(match[1]),
+ offset: Number(match[2]),
+ endOffset: match[3] ? Number(match[3]) : Number(match[2])
+ } : null;
+ }
+
+ async goToLocator(rawLocator) {
+ const locator = this._normalizeLocator(rawLocator);
+ if (!locator || !locator.sectionId) return false;
+ const section = document.getElementById(locator.sectionId);
+ if (!section || !this.documentElement.contains(section)) return false;
+ const point = textPoint(section, locator.offset);
+ const range = document.createRange();
+ try {
+ range.setStart(point.node, point.offset);
+ range.collapse(true);
+ } catch (_) {
+ range.selectNode(section);
+ range.collapse(true);
+ }
+ const rect = range.getBoundingClientRect();
+ const mountRect = this.mount.getBoundingClientRect();
+ if (this.host.getPreferences().verticalScroll) {
+ const absoluteTop = rect.top - mountRect.top + this.mount.scrollTop;
+ this.mount.scrollTop = Math.max(0, absoluteTop - 18);
+ } else {
+ const margin = this.host.getPreferences().margin;
+ const absoluteLeft = rect.left - mountRect.left - margin + this.currentPage * this.mount.clientWidth;
+ this._showPage(Math.floor(Math.max(0, absoluteLeft) / Math.max(1, this.mount.clientWidth)));
+ }
+ this._emitProgress();
+ return true;
+ }
+
+ async goToChapter(href) {
+ const raw = String(href || '').replace(/^#/, '');
+ const id = document.getElementById(raw) ? raw : safeId(raw, raw);
+ return this.goToLocator({ type: 'fb2', sectionId: id, offset: 0 });
+ }
+
+ async search(query) {
+ this.clearSearch(false);
+ const normalizedQuery = String(query || '').trim();
+ const generation = ++this.searchState.generation;
+ this.searchState.query = normalizedQuery;
+ this.searchState.searching = Boolean(normalizedQuery);
+ this._postSearch();
+ if (!normalizedQuery) return [];
+ const needle = normalizedQuery.toLocaleLowerCase();
+ const blocks = Array.from(this.documentElement.querySelectorAll('p,h1,h2,h3,h4,h5,h6,pre,td,th'));
+ const results = [];
+ for (let blockIndex = 0; blockIndex < blocks.length && results.length < 500; blockIndex += 1) {
+ if (generation !== this.searchState.generation) return [];
+ const block = blocks[blockIndex];
+ const section = closestSection(block, this.documentElement);
+ if (!section) continue;
+ const text = block.textContent || '';
+ const haystack = text.toLocaleLowerCase();
+ let match = haystack.indexOf(needle);
+ while (match >= 0 && results.length < 500) {
+ const blockStart = offsetWithin(section, block, 0);
+ const start = blockStart + match;
+ results.push({
+ locator: {
+ type: 'fb2', sectionId: section.id,
+ offset: start, endOffset: start + normalizedQuery.length
+ },
+ excerpt: Internal.makeExcerpt(text, match, normalizedQuery.length),
+ chapter: this._chapterForSection(section)
+ });
+ match = haystack.indexOf(needle, match + Math.max(1, needle.length));
+ }
+ }
+ if (generation !== this.searchState.generation) return [];
+ this.searchState.results = results;
+ this.searchState.currentIndex = results.length ? 0 : -1;
+ this.searchState.searching = false;
+ if (results.length) await this._showSearchResult(0);
+ else this._postSearch();
+ return results;
+ }
+
+ async nextSearch() {
+ return this._showSearchResult(this.searchState.currentIndex + 1);
+ }
+
+ async previousSearch() {
+ return this._showSearchResult(this.searchState.currentIndex - 1);
+ }
+
+ async _showSearchResult(index) {
+ const results = this.searchState.results;
+ if (!results.length) {
+ this._postSearch();
+ return null;
+ }
+ const normalized = ((index % results.length) + results.length) % results.length;
+ this.searchState.currentIndex = normalized;
+ this._clearSearchMarks();
+ const result = results[normalized];
+ this.searchMarks = this._wrapLocator(result.locator, 'reader-search-current', { 'data-search-marker': 'true' });
+ await this.goToLocator(result.locator);
+ this._postSearch();
+ return result;
+ }
+
+ _postSearch() {
+ const result = this.searchState.currentIndex >= 0
+ ? this.searchState.results[this.searchState.currentIndex]
+ : null;
+ Internal.post('search', {
+ query: this.searchState.query,
+ total: this.searchState.results.length,
+ currentIndex: this.searchState.currentIndex,
+ searching: this.searchState.searching,
+ truncated: this.searchState.results.length >= 500,
+ result: result
+ });
+ }
+
+ _clearSearchMarks() {
+ this.searchMarks.forEach(unwrap);
+ this.searchMarks = [];
+ }
+
+ clearSearch(emit) {
+ this._clearSearchMarks();
+ const generation = this.searchState.generation + 1;
+ this.searchState = { query: '', results: [], currentIndex: -1, generation: generation, searching: false };
+ if (emit !== false) this._postSearch();
+ }
+
+ clearSelection() {
+ try { global.getSelection().removeAllRanges(); } catch (_) {}
+ }
+
+ _wrapLocator(rawLocator, className, attributes) {
+ const locator = this._normalizeLocator(rawLocator);
+ if (!locator || !locator.sectionId) return [];
+ const section = document.getElementById(locator.sectionId);
+ if (!section) return [];
+ const start = Math.max(0, Number(locator.offset) || 0);
+ const end = Math.max(start + 1, Number(locator.endOffset) || start + 1);
+ const walker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT, null);
+ const candidates = [];
+ let node;
+ let consumed = 0;
+ while ((node = walker.nextNode())) {
+ const length = node.nodeValue.length;
+ const nodeStart = consumed;
+ const nodeEnd = consumed + length;
+ if (end > nodeStart && start < nodeEnd && !node.parentElement.closest('script,style')) {
+ candidates.push({
+ node: node,
+ start: Math.max(0, start - nodeStart),
+ end: Math.min(length, end - nodeStart)
+ });
+ }
+ consumed = nodeEnd;
+ if (consumed >= end) break;
+ }
+ const marks = [];
+ candidates.forEach(function (candidate) {
+ if (candidate.end <= candidate.start || !candidate.node.parentNode) return;
+ const range = document.createRange();
+ range.setStart(candidate.node, candidate.start);
+ range.setEnd(candidate.node, candidate.end);
+ const mark = document.createElement('mark');
+ mark.className = className;
+ Object.keys(attributes || {}).forEach(function (name) { mark.setAttribute(name, attributes[name]); });
+ try {
+ range.surroundContents(mark);
+ marks.push(mark);
+ } catch (_) {}
+ });
+ return marks;
+ }
+
+ addHighlight(locator, color) {
+ const normalized = this._normalizeLocator(locator);
+ if (!normalized) return false;
+ const key = Internal.stableLocatorKey(normalized);
+ this.removeHighlight(normalized);
+ const normalizedColor = typeof color === 'string' && color.trim() ? color.trim() : '#ffd84a';
+ const marks = this._wrapLocator(normalized, 'reader-highlight', {
+ 'data-highlight-key': key,
+ 'data-highlight-color': normalizedColor
+ });
+ marks.forEach(function (mark) { mark.style.backgroundColor = normalizedColor; });
+ if (!marks.length) return false;
+ this.userHighlights.set(key, { locator: normalized, color: normalizedColor });
+ return true;
+ }
+
+ removeHighlight(locator) {
+ const normalized = this._normalizeLocator(locator);
+ if (!normalized) return false;
+ const key = Internal.stableLocatorKey(normalized);
+ Array.from(this.documentElement.querySelectorAll('mark[data-highlight-key]')).forEach(function (mark) {
+ if (mark.getAttribute('data-highlight-key') === key) unwrap(mark);
+ });
+ return this.userHighlights.delete(key);
+ }
+
+ setHighlights(list) {
+ Array.from(this.documentElement.querySelectorAll('mark[data-highlight-key]')).forEach(unwrap);
+ this.userHighlights.clear();
+ (Array.isArray(list) ? list : []).forEach(function (item) {
+ if (item) this.addHighlight(item.locator || item, item.color);
+ }, this);
+ return this.userHighlights.size;
+ }
+
+ speechPage(maxCharacters) {
+ return {
+ text: Internal.visibleSpeechText(document, maxCharacters),
+ locator: this.currentLocator(),
+ canAdvance: this.currentPage + 1 < this.totalPages
+ };
+ }
+
+ state() {
+ return {
+ format: 'fb2',
+ ready: this.ready,
+ locator: this.currentLocator(),
+ progress: this.currentProgress,
+ currentPage: this.currentPage + 1,
+ totalPages: this.totalPages,
+ tocCount: this.toc.length,
+ search: {
+ query: this.searchState.query,
+ total: this.searchState.results.length,
+ currentIndex: this.searchState.currentIndex,
+ searching: this.searchState.searching
+ },
+ highlightCount: this.userHighlights.size
+ };
+ }
+
+ destroy() {
+ this.destroyed = true;
+ clearTimeout(this.resizeTimer);
+ clearTimeout(this.selectionTimer);
+ if (this.scrollFrame) cancelAnimationFrame(this.scrollFrame);
+ if (this.resizeObserver) this.resizeObserver.disconnect();
+ this.resizeObserver = null;
+ this.mount.removeEventListener('click', this._onClick);
+ this.mount.removeEventListener('scroll', this._onScroll);
+ document.removeEventListener('selectionchange', this._onSelectionChange);
+ if (this.detachGestures) this.detachGestures();
+ this.detachGestures = null;
+ this.clearSearch(false);
+ this.binaryUrls.forEach(function (url) { URL.revokeObjectURL(url); });
+ this.binaryUrls.clear();
+ this.userHighlights.clear();
+ this.documentElement.replaceChildren();
+ }
+ }
+
+ Internal.Fb2Engine = Fb2Engine;
+})(window);
diff --git a/app/src/main/assets/reader_v2/js/gestures.js b/app/src/main/assets/reader_v2/js/gestures.js
new file mode 100644
index 0000000..17f5377
--- /dev/null
+++ b/app/src/main/assets/reader_v2/js/gestures.js
@@ -0,0 +1,147 @@
+(function (global) {
+ 'use strict';
+
+ const Internal = global.ReaderV2Internal = global.ReaderV2Internal || {};
+ const attachedDocuments = new WeakMap();
+
+ function selectionText(doc) {
+ try {
+ return String(doc.defaultView.getSelection().toString() || '').trim();
+ } catch (_) {
+ return '';
+ }
+ }
+
+ function interactiveTarget(target) {
+ return Boolean(target && target.closest && target.closest('a,button,input,textarea,select,summary,[contenteditable="true"]'));
+ }
+
+ function attachGestures(doc, options) {
+ if (!doc || attachedDocuments.has(doc)) return attachedDocuments.get(doc) || function () {};
+
+ let startX = 0;
+ let startY = 0;
+ let startAt = 0;
+ let startTarget = null;
+ let consumedAt = 0;
+ let moved = false;
+ let brightnessCandidate = false;
+
+ function preferences() {
+ return typeof options.getPreferences === 'function' ? options.getPreferences() : {};
+ }
+
+ function onTouchStart(event) {
+ if (!event.touches || event.touches.length !== 1) return;
+ const touch = event.touches[0];
+ startX = touch.clientX;
+ startY = touch.clientY;
+ startAt = Date.now();
+ startTarget = event.target;
+ moved = false;
+ const width = doc.documentElement.clientWidth || global.innerWidth;
+ brightnessCandidate = Boolean(preferences().brightnessGesture && startX <= width * .18);
+ }
+
+ function onTouchMove(event) {
+ if (!event.touches || event.touches.length !== 1) return;
+ const touch = event.touches[0];
+ const dx = touch.clientX - startX;
+ const dy = touch.clientY - startY;
+ if (Math.abs(dx) > 10 || Math.abs(dy) > 10) moved = true;
+ if (brightnessCandidate && Math.abs(dy) > 6 && Math.abs(dy) > Math.abs(dx) * 1.15) {
+ event.preventDefault();
+ }
+ }
+
+ function doTap(x, width) {
+ const current = preferences();
+ const mode = current.pageTurnMode || 'tapSwipe';
+ const normalized = x / Math.max(1, width);
+ if (normalized >= .28 && normalized <= .72) {
+ options.toggleControls();
+ return;
+ }
+ if (mode !== 'tapSwipe' && mode !== 'tap') return;
+ if (normalized < .28) current.invertZones ? options.next() : options.previous();
+ else current.invertZones ? options.previous() : options.next();
+ }
+
+ function onTouchEnd(event) {
+ const touch = event.changedTouches && event.changedTouches[0];
+ if (!touch || !startAt) return;
+ const elapsed = Date.now() - startAt;
+ const dx = touch.clientX - startX;
+ const dy = touch.clientY - startY;
+ const horizontal = Math.abs(dx) > Math.abs(dy) * 1.25;
+ const current = preferences();
+ const mode = current.pageTurnMode || 'tapSwipe';
+ const viewportWidth = doc.documentElement.clientWidth || global.innerWidth;
+ const viewportHeight = doc.documentElement.clientHeight || global.innerHeight;
+
+ if (interactiveTarget(startTarget) || selectionText(doc)) return;
+
+ if (
+ current.brightnessGesture &&
+ startX <= viewportWidth * .18 &&
+ elapsed < 1200 &&
+ Math.abs(dy) >= 28 &&
+ Math.abs(dy) > Math.abs(dx) * 1.35
+ ) {
+ Internal.post('brightnessDelta', {
+ delta: Internal.clamp((-dy / Math.max(1, viewportHeight)) * 100, -100, 100)
+ });
+ consumedAt = Date.now();
+ return;
+ }
+
+ if (elapsed < 650 && horizontal && Math.abs(dx) >= 48 && (mode === 'tapSwipe' || mode === 'swipe')) {
+ dx < 0 ? options.next() : options.previous();
+ consumedAt = Date.now();
+ return;
+ }
+
+ if (elapsed < 300 && !moved && Math.abs(dx) < 12 && Math.abs(dy) < 12) {
+ doTap(touch.clientX, doc.documentElement.clientWidth || global.innerWidth);
+ consumedAt = Date.now();
+ }
+ }
+
+ function onClick(event) {
+ if (Date.now() - consumedAt < 500 || interactiveTarget(event.target) || selectionText(doc)) return;
+ doTap(event.clientX, doc.documentElement.clientWidth || global.innerWidth);
+ }
+
+ function onKeyDown(event) {
+ if (interactiveTarget(event.target)) return;
+ if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
+ event.preventDefault();
+ options.previous();
+ } else if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {
+ event.preventDefault();
+ options.next();
+ } else if (event.key === 'Escape') {
+ options.toggleControls();
+ }
+ }
+
+ doc.addEventListener('touchstart', onTouchStart, { passive: true });
+ doc.addEventListener('touchmove', onTouchMove, { passive: false });
+ doc.addEventListener('touchend', onTouchEnd, { passive: true });
+ doc.addEventListener('click', onClick, false);
+ doc.addEventListener('keydown', onKeyDown, false);
+
+ const detach = function () {
+ doc.removeEventListener('touchstart', onTouchStart);
+ doc.removeEventListener('touchmove', onTouchMove);
+ doc.removeEventListener('touchend', onTouchEnd);
+ doc.removeEventListener('click', onClick);
+ doc.removeEventListener('keydown', onKeyDown);
+ attachedDocuments.delete(doc);
+ };
+ attachedDocuments.set(doc, detach);
+ return detach;
+ }
+
+ Internal.attachGestures = attachGestures;
+})(window);
diff --git a/app/src/main/assets/reader_v2/js/reader.js b/app/src/main/assets/reader_v2/js/reader.js
new file mode 100644
index 0000000..32c1f18
--- /dev/null
+++ b/app/src/main/assets/reader_v2/js/reader.js
@@ -0,0 +1,268 @@
+(function (global) {
+ 'use strict';
+
+ const Internal = global.ReaderV2Internal;
+ const epubMount = document.getElementById('epub-viewer');
+ const fb2Mount = document.getElementById('fb2-viewer');
+ const status = document.getElementById('reader-status');
+ const statusText = document.getElementById('reader-status-text');
+
+ const state = {
+ preferences: Internal.normalizePreferences({}, Internal.DEFAULT_PREFERENCES),
+ engine: null,
+ format: null,
+ payload: null,
+ loading: false,
+ loadGeneration: 0,
+ abortController: null,
+ lastError: null
+ };
+
+ function parseObject(value, label) {
+ if (value === undefined || value === null) return {};
+ if (typeof value === 'string') {
+ try { return JSON.parse(value); }
+ catch (_) { throw new Error((label || 'Данные') + ': передан некорректный JSON.'); }
+ }
+ if (typeof value !== 'object') throw new Error((label || 'Данные') + ': ожидался объект.');
+ return value;
+ }
+
+ function showStatus(text, isError) {
+ statusText.textContent = String(text || '');
+ status.classList.toggle('is-error', Boolean(isError));
+ status.classList.remove('is-hidden');
+ }
+
+ function hideStatus() {
+ status.classList.add('is-hidden');
+ status.classList.remove('is-error');
+ }
+
+ function activateMount(format) {
+ epubMount.classList.toggle('is-active', format === 'epub');
+ fb2Mount.classList.toggle('is-active', format === 'fb2');
+ }
+
+ function destroyEngine() {
+ if (!state.engine) return;
+ try { state.engine.destroy(); } catch (_) {}
+ state.engine = null;
+ state.format = null;
+ }
+
+ function layoutPreferencesChanged(previous, next) {
+ return [
+ 'fontFamily', 'fontSize', 'lineHeight', 'margin',
+ 'textAlign', 'theme', 'verticalScroll'
+ ].some(function (key) { return previous[key] !== next[key]; });
+ }
+
+ function callEngine(method, args, fallback) {
+ if (!state.engine || typeof state.engine[method] !== 'function') return Promise.resolve(fallback);
+ try {
+ return Promise.resolve(state.engine[method].apply(state.engine, args || [])).catch(function (error) {
+ Internal.reportError(error, 'api.' + method, true);
+ return fallback;
+ });
+ } catch (error) {
+ Internal.reportError(error, 'api.' + method, true);
+ return Promise.resolve(fallback);
+ }
+ }
+
+ const host = {
+ getPreferences: function () { return state.preferences; },
+ next: function () { return ReaderV2.next(); },
+ previous: function () { return ReaderV2.previous(); },
+ toggleControls: function () { Internal.post('toggleControls', {}); }
+ };
+
+ async function loadBook(rawPayload) {
+ const payload = parseObject(rawPayload, 'Книга');
+ if (typeof payload.url !== 'string' || !payload.url.trim()) {
+ throw new Error('Для загрузки книги требуется payload.url.');
+ }
+ const generation = ++state.loadGeneration;
+ if (state.abortController) state.abortController.abort();
+ state.abortController = typeof AbortController === 'function' ? new AbortController() : null;
+ state.loading = true;
+ state.lastError = null;
+ showStatus('Загрузка книги…', false);
+
+ if (payload.preferences) await setPreferences(payload.preferences);
+
+ let nextEngine = null;
+ try {
+ const response = await fetch(payload.url, {
+ method: 'GET',
+ cache: 'no-store',
+ credentials: 'same-origin',
+ signal: state.abortController && state.abortController.signal
+ });
+ if (!response.ok && response.status !== 0) {
+ const error = new Error('Не удалось получить книгу: HTTP ' + response.status + '.');
+ error.code = 'BookFetchHttpError';
+ throw error;
+ }
+ const arrayBuffer = await response.arrayBuffer();
+ if (generation !== state.loadGeneration) return null;
+ if (!arrayBuffer.byteLength) throw new Error('Получен пустой файл книги.');
+
+ const format = Internal.inferFormat(payload, new Uint8Array(arrayBuffer, 0, Math.min(arrayBuffer.byteLength, 1024)));
+ destroyEngine();
+ activateMount(format);
+ nextEngine = format === 'epub'
+ ? new Internal.EpubEngine(epubMount, host)
+ : new Internal.Fb2Engine(fb2Mount, host);
+ state.engine = nextEngine;
+ state.format = format;
+ state.payload = {
+ id: payload.id === undefined ? null : payload.id,
+ title: payload.title || '',
+ author: payload.author || '',
+ url: payload.url
+ };
+
+ showStatus(format === 'epub' ? 'Подготовка EPUB…' : 'Подготовка FB2…', false);
+ const result = await nextEngine.load(arrayBuffer, payload);
+ if (generation !== state.loadGeneration || state.engine !== nextEngine) {
+ nextEngine.destroy();
+ if (state.engine === nextEngine) {
+ state.engine = null;
+ state.format = null;
+ activateMount(null);
+ }
+ return null;
+ }
+ if (Array.isArray(payload.highlights)) nextEngine.setHighlights(payload.highlights);
+ state.loading = false;
+ hideStatus();
+ await Internal.nextFrame();
+ Internal.post('bookReady', Object.assign({}, result, {
+ id: state.payload.id,
+ preferences: state.preferences
+ }));
+ return result;
+ } catch (error) {
+ if (error && error.name === 'AbortError') return null;
+ if (generation !== state.loadGeneration) return null;
+ state.loading = false;
+ state.lastError = Internal.reportError(error, 'book.load', true);
+ showStatus(state.lastError.message, true);
+ if (nextEngine && state.engine === nextEngine) {
+ destroyEngine();
+ activateMount(null);
+ }
+ return null;
+ }
+ }
+
+ async function setPreferences(rawPreferences) {
+ const input = parseObject(rawPreferences, 'Настройки');
+ const previous = state.preferences;
+ const next = Internal.normalizePreferences(input, previous);
+ state.preferences = next;
+ Internal.applyShellPreferences(next);
+ const preferredFontReady = await Internal.waitForPreferredFont(document, next);
+ const preferredFontFamily = Internal.getBundledFontFamily(next);
+ if (preferredFontFamily && !preferredFontReady) {
+ state.preferences = previous;
+ Internal.applyShellPreferences(previous);
+ throw new Error('Не удалось загрузить встроенный шрифт «' + preferredFontFamily + '».');
+ }
+ if (
+ state.engine &&
+ typeof state.engine.applyPreferences === 'function' &&
+ layoutPreferencesChanged(previous, next)
+ ) {
+ try { await state.engine.applyPreferences(next, previous); }
+ catch (error) { Internal.reportError(error, 'preferences.apply', true); }
+ }
+ return next;
+ }
+
+ function getState() {
+ return {
+ version: 2,
+ loading: state.loading,
+ format: state.format,
+ book: state.payload,
+ preferences: state.preferences,
+ error: state.lastError,
+ reader: state.engine && typeof state.engine.state === 'function' ? state.engine.state() : null
+ };
+ }
+
+ const ReaderV2 = {
+ loadBook: function (payload) {
+ return loadBook(payload).catch(function (error) {
+ state.lastError = Internal.reportError(error, 'api.loadBook', true);
+ showStatus(state.lastError.message, true);
+ return null;
+ });
+ },
+ setPreferences: function (preferences) {
+ return setPreferences(preferences).catch(function (error) {
+ Internal.reportError(error, 'api.setPreferences', true);
+ return state.preferences;
+ });
+ },
+ next: function () { return callEngine('next', [], false); },
+ previous: function () { return callEngine('previous', [], false); },
+ goToProgress: function (value) { return callEngine('goToProgress', [value], false); },
+ goToLocator: function (locator) { return callEngine('goToLocator', [locator], false); },
+ goToChapter: function (href) { return callEngine('goToChapter', [href], false); },
+ getStateJson: function () {
+ try { return JSON.stringify(getState()); }
+ catch (error) {
+ Internal.reportError(error, 'api.getStateJson', true);
+ return JSON.stringify({ version: 2, error: 'StateSerializationError' });
+ }
+ },
+ getSpeechPageJson: function (maxCharacters) {
+ try {
+ return JSON.stringify(callEngine('speechPage', [maxCharacters], {
+ text: '', locator: null, canAdvance: false
+ }));
+ } catch (error) {
+ Internal.reportError(error, 'api.getSpeechPageJson', true);
+ return JSON.stringify({ text: '', locator: null, canAdvance: false });
+ }
+ },
+ search: function (query) { return callEngine('search', [String(query || '')], []); },
+ nextSearch: function () { return callEngine('nextSearch', [], null); },
+ previousSearch: function () { return callEngine('previousSearch', [], null); },
+ clearSearch: function () { return callEngine('clearSearch', [], undefined); },
+ clearSelection: function () { return callEngine('clearSelection', [], undefined); },
+ addHighlight: function (locator, color) { return callEngine('addHighlight', [locator, color], false); },
+ removeHighlight: function (locator) { return callEngine('removeHighlight', [locator], false); },
+ setHighlights: function (list) { return callEngine('setHighlights', [Array.isArray(list) ? list : []], 0); }
+ };
+
+ global.ReaderV2 = Object.freeze(ReaderV2);
+ Internal.applyShellPreferences(state.preferences);
+
+ global.addEventListener('error', function (event) {
+ if (!event || !event.error) return;
+ Internal.reportError(event.error, 'javascript.runtime', true);
+ });
+ global.addEventListener('unhandledrejection', function (event) {
+ if (!event) return;
+ Internal.reportError(event.reason || 'Необработанная ошибка Promise.', 'javascript.promise', true);
+ });
+ global.addEventListener('beforeunload', function () {
+ if (state.abortController) state.abortController.abort();
+ destroyEngine();
+ });
+
+ hideStatus();
+ Internal.post('shellReady', {
+ formats: ['epub', 'fb2'],
+ api: [
+ 'loadBook', 'setPreferences', 'next', 'previous', 'goToProgress', 'goToLocator',
+ 'goToChapter', 'getStateJson', 'getSpeechPageJson', 'search', 'nextSearch', 'previousSearch',
+ 'clearSearch', 'clearSelection', 'addHighlight', 'removeHighlight', 'setHighlights'
+ ]
+ });
+})(window);
diff --git a/app/src/main/assets/wwwroot/index.html b/app/src/main/assets/wwwroot/index.html
index 3d79034..fefcdb7 100644
--- a/app/src/main/assets/wwwroot/index.html
+++ b/app/src/main/assets/wwwroot/index.html
@@ -526,7 +526,11 @@
// Обработка оглавления
const toc = state.book.navigation.toc || [];
- state.toc = toc.map(ch => ({ label: ch.label.trim(), href: ch.href }));
+ const flattenToc = (items, depth) => (items || []).flatMap(ch => [
+ { label: ch.label.trim(), href: ch.href, depth: depth || 0 },
+ ...flattenToc(ch.subitems || [], (depth || 0) + 1)
+ ]);
+ state.toc = flattenToc(toc, 0);
sendMessage('chaptersLoaded', { chapters: state.toc });
// ПРОВЕРКА РљРРЁРђ: Если РјС‹ СѓР¶Рµ передали сохраненные локации
@@ -565,8 +569,13 @@
// Обновляем lastCfi
state.lastCfi = newCfi;
state.currentCfi = newCfi;
- state.currentPage = Math.round(progress * 100);
- state.totalPages = 100;
+ const generatedTotal = Math.max(1, state.book.locations.length() || state.totalPages || 1);
+ const locationIndex = state.book.locations.locationFromCfi(newCfi);
+ const generatedPage = Number.isFinite(locationIndex)
+ ? locationIndex + 1
+ : Math.round(progress * Math.max(0, generatedTotal - 1)) + 1;
+ state.currentPage = Math.max(1, Math.min(generatedPage, generatedTotal));
+ state.totalPages = generatedTotal;
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
@@ -577,8 +586,8 @@
sendMessage('progressUpdate', {
progress: progress,
cfi: newCfi,
- currentPage: Math.round(progress * 100), // Процент вместо номера страницы
- totalPages: 100, // 100%
+ currentPage: state.currentPage,
+ totalPages: state.totalPages,
chapterCurrentPage: chapterPage,
chapterTotalPages: chapterTotal,
chapter: chapterName
@@ -772,7 +781,7 @@
// Основная магия: CSS превращает длинный текст в ряд колонок шириной с экран
Object.assign(inner.style, {
columnWidth: w + 'px',
- columnGap: '40px', // Зазор между колонками
+ columnGap: '0px',
columnFill: 'auto',
height: h + 'px',
overflow: 'hidden',
@@ -1099,6 +1108,19 @@
}
};
+ window.goToProgress = function (value) {
+ const progress = Math.max(0, Math.min(1, Number(value) || 0));
+ if (state.bookFormat === 'epub' && state.rendition && state.book && state.book.locations.length() > 0) {
+ const target = state.book.locations.cfiFromPercentage(progress);
+ if (target) state.rendition.display(target);
+ }
+ else if (state.bookFormat === 'fb2') {
+ const targetPage = Math.round(progress * Math.max(0, state.fb2TotalPages - 1));
+ showFb2Page(targetPage);
+ updateFb2Progress();
+ }
+ };
+
function applyEpubReaderStyles(palette) {
if (!state.rendition) {
return;
@@ -1187,7 +1209,7 @@
}
window.setReaderTheme = function (theme) {
- state.currentTheme = theme || 'sepia';
+ state.currentTheme = theme || 'light';
applyReaderTheme();
};
diff --git a/app/src/main/java/com/aletheia/app/AletheiaApplication.kt b/app/src/main/java/com/aletheia/app/AletheiaApplication.kt
index 4f56e82..a497a08 100644
--- a/app/src/main/java/com/aletheia/app/AletheiaApplication.kt
+++ b/app/src/main/java/com/aletheia/app/AletheiaApplication.kt
@@ -4,12 +4,22 @@ import android.app.Application
import com.aletheia.app.data.AppDatabaseHelper
import com.aletheia.app.data.BookParserService
import com.aletheia.app.data.BookRepository
+import com.aletheia.app.data.CatalogCache
import com.aletheia.app.data.QBooksService
import com.aletheia.app.data.SettingsRepository
import com.aletheia.app.diagnostics.DiagnosticsReporter
+import com.aletheia.app.ui.reader.ReaderStateCoordinator
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.sync.Mutex
import xyz.kusoft.argusupdater.ArgusUpdateManager
class AletheiaApplication : Application() {
+ val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ val readerPersistenceMutex = Mutex()
+ val readerStateCoordinator = ReaderStateCoordinator()
+
lateinit var diagnosticsReporter: DiagnosticsReporter
private set
@@ -28,6 +38,9 @@ class AletheiaApplication : Application() {
lateinit var qBooksService: QBooksService
private set
+ lateinit var catalogCache: CatalogCache
+ private set
+
lateinit var appUpdateManager: ArgusUpdateManager
private set
@@ -40,7 +53,8 @@ class AletheiaApplication : Application() {
settingsRepository = SettingsRepository(this, databaseHelper)
bookParserService = BookParserService(this)
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
- qBooksService = QBooksService(settingsRepository)
+ catalogCache = CatalogCache(this)
+ qBooksService = QBooksService(settingsRepository, catalogCache)
appUpdateManager = ArgusUpdateManager(this)
}
}
diff --git a/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt b/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt
index 017f9a7..2f1abee 100644
--- a/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt
+++ b/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt
@@ -78,6 +78,14 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
if (oldVersion < 5) {
migrateRemoteIdColumn(db)
}
+ if (oldVersion < 6) {
+ if (!hasColumn(db, "reading_notes", "highlight_color")) {
+ db.execSQL("ALTER TABLE reading_notes ADD COLUMN highlight_color TEXT")
+ }
+ if (!hasColumn(db, "reading_notes", "kind")) {
+ db.execSQL("ALTER TABLE reading_notes ADD COLUMN kind TEXT NOT NULL DEFAULT 'note'")
+ }
+ }
}
fun getAllBooks(): List = readableDatabase.query(
@@ -145,6 +153,25 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
if (cursor.moveToFirst()) cursor.getString(0) else null
}
+ fun getSettings(keys: Collection): Map {
+ if (keys.isEmpty()) return emptyMap()
+ val orderedKeys = keys.distinct()
+ val placeholders = orderedKeys.joinToString(",") { "?" }
+ return readableDatabase.query(
+ "app_settings",
+ arrayOf("key", "value"),
+ "key IN ($placeholders)",
+ orderedKeys.toTypedArray(),
+ null,
+ null,
+ null
+ ).use { cursor ->
+ buildMap {
+ while (cursor.moveToNext()) put(cursor.getString(0), cursor.getString(1))
+ }
+ }
+ }
+
fun setSetting(key: String, value: String) {
writableDatabase.insertWithOnConflict(
"app_settings",
@@ -157,6 +184,28 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
)
}
+ fun setSettings(values: Map) {
+ if (values.isEmpty()) return
+ val database = writableDatabase
+ database.beginTransaction()
+ try {
+ values.forEach { (key, value) ->
+ database.insertWithOnConflict(
+ "app_settings",
+ null,
+ ContentValues().apply {
+ put("key", key)
+ put("value", value)
+ },
+ SQLiteDatabase.CONFLICT_REPLACE
+ )
+ }
+ database.setTransactionSuccessful()
+ } finally {
+ database.endTransaction()
+ }
+ }
+
fun saveProgress(progress: ReadingProgress) {
writableDatabase.beginTransaction()
try {
@@ -285,6 +334,8 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
put("selected_text", note.selectedText)
put("note_text", note.noteText)
put("created_at", note.createdAt)
+ put("highlight_color", note.highlightColor)
+ put("kind", note.kind)
}
return if (note.id == 0L) {
@@ -358,7 +409,9 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
chapterTitle = cursor.getString(cursor.getColumnIndexOrThrow("chapter_title")),
selectedText = cursor.getString(cursor.getColumnIndexOrThrow("selected_text")),
noteText = cursor.getString(cursor.getColumnIndexOrThrow("note_text")),
- createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at"))
+ createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")),
+ highlightColor = cursor.getString(cursor.getColumnIndexOrThrow("highlight_color")),
+ kind = cursor.getString(cursor.getColumnIndexOrThrow("kind"))
)
private fun createBookmarksTable(db: SQLiteDatabase) {
@@ -398,7 +451,9 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
chapter_title TEXT,
selected_text TEXT,
note_text TEXT NOT NULL,
- created_at INTEGER NOT NULL
+ created_at INTEGER NOT NULL,
+ highlight_color TEXT,
+ kind TEXT NOT NULL DEFAULT 'note'
)
""".trimIndent()
)
@@ -432,7 +487,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
companion object {
private const val DATABASE_NAME = "aletheia.db3"
- private const val DATABASE_VERSION = 5
+ private const val DATABASE_VERSION = 6
private const val MAX_READING_HISTORY_ENTRIES = 200
}
}
diff --git a/app/src/main/java/com/aletheia/app/data/BookRepository.kt b/app/src/main/java/com/aletheia/app/data/BookRepository.kt
index 23d4cf6..00e2261 100644
--- a/app/src/main/java/com/aletheia/app/data/BookRepository.kt
+++ b/app/src/main/java/com/aletheia/app/data/BookRepository.kt
@@ -6,6 +6,7 @@ import com.aletheia.app.model.ReadingBookmark
import com.aletheia.app.model.ReadingNote
import com.aletheia.app.model.ReadingProgress
import java.io.File
+import java.io.IOException
class BookRepository(
private val databaseHelper: AppDatabaseHelper,
@@ -26,6 +27,9 @@ class BookRepository(
fun getBookById(id: Long): Book? = databaseHelper.getBookById(id)
+ fun getBookByRemoteId(remoteId: String): Book? =
+ databaseHelper.getAllBooks().firstOrNull { it.remoteId == remoteId }
+
fun importBook(uri: Uri): Book {
val book = parserService.parseAndStoreBookFromUri(uri)
val id = databaseHelper.saveBook(book)
@@ -40,8 +44,14 @@ class BookRepository(
}
fun deleteBook(book: Book) {
- runCatching {
- File(book.filePath).takeIf { it.exists() }?.delete()
+ val booksDirectory = parserService.getBooksDirectory().canonicalFile
+ val bookFile = File(book.filePath).canonicalFile
+ val isStoredBook = bookFile.parentFile == booksDirectory
+ require(isStoredBook) {
+ "Файл книги находится вне внутренней папки библиотеки."
+ }
+ if (bookFile.exists() && !bookFile.delete()) {
+ throw IOException("Не удалось удалить скачанный файл книги.")
}
databaseHelper.deleteBook(book)
}
@@ -124,7 +134,9 @@ class BookRepository(
totalPages: Int,
chapter: String?,
selectedText: String?,
- noteText: String
+ noteText: String,
+ highlightColor: String? = null,
+ kind: String = ReadingNote.KIND_NOTE
): ReadingNote {
val note = ReadingNote(
bookId = bookId,
@@ -134,7 +146,9 @@ class BookRepository(
totalPages = totalPages,
chapterTitle = chapter,
selectedText = selectedText,
- noteText = noteText
+ noteText = noteText,
+ highlightColor = highlightColor,
+ kind = kind
)
val id = databaseHelper.saveNote(note)
return note.copy(id = id)
@@ -145,13 +159,13 @@ class BookRepository(
}
fun getDefaultFontSize(): Int =
- settingsRepository.getInt(SettingsRepository.KEY_DEFAULT_FONT_SIZE, 18)
+ settingsRepository.getInt(SettingsRepository.KEY_DEFAULT_FONT_SIZE, 20)
fun getDefaultFontFamily(): String =
settingsRepository.getString(SettingsRepository.KEY_DEFAULT_FONT_FAMILY, "serif")
fun getDefaultTheme(): String =
- settingsRepository.getString(SettingsRepository.KEY_THEME, "sepia")
+ settingsRepository.getString(SettingsRepository.KEY_THEME, "light")
fun getDefaultBrightness(): Double =
settingsRepository.getDouble(SettingsRepository.KEY_BRIGHTNESS, 100.0)
diff --git a/app/src/main/java/com/aletheia/app/data/CatalogCache.kt b/app/src/main/java/com/aletheia/app/data/CatalogCache.kt
new file mode 100644
index 0000000..f4048a0
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/data/CatalogCache.kt
@@ -0,0 +1,210 @@
+package com.aletheia.app.data
+
+import android.content.Context
+import android.util.AtomicFile
+import com.aletheia.app.model.CatalogPage
+import com.aletheia.app.model.QBooksBook
+import java.io.File
+import java.security.MessageDigest
+import org.json.JSONArray
+import org.json.JSONObject
+
+class CatalogCache(context: Context) {
+ private val persistentDirectory = File(context.noBackupFilesDir, "CatalogCache-v1").apply { mkdirs() }
+ private val pagesDirectory = File(persistentDirectory, "pages").apply { mkdirs() }
+ private val booksDirectory = File(persistentDirectory, "books").apply { mkdirs() }
+ private val metadataAttemptsDirectory = File(persistentDirectory, "metadata-attempts").apply { mkdirs() }
+ private val coversDirectory = File(context.cacheDir, "catalog-v1/covers").apply { mkdirs() }
+ private val lock = Any()
+
+ data class CachedPage(
+ val page: CatalogPage,
+ val savedAtMillis: Long
+ ) {
+ fun isFresh(maxAgeMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean =
+ maxAgeMillis > 0L && nowMillis - savedAtMillis in 0..maxAgeMillis
+ }
+
+ fun readPage(key: String): CachedPage? = synchronized(lock) {
+ val file = File(pagesDirectory, "${key.sha256()}.json")
+ if (!file.isFile) return@synchronized null
+ runCatching {
+ val json = JSONObject(file.readText(Charsets.UTF_8))
+ val booksJson = json.optJSONArray("books") ?: JSONArray()
+ val books = buildList {
+ for (index in 0 until booksJson.length()) {
+ booksJson.optJSONObject(index)?.toBook()?.let(::add)
+ }
+ }
+ file.setLastModified(System.currentTimeMillis())
+ CachedPage(
+ page = CatalogPage(
+ books = books,
+ nextPageUrl = json.nullableString("nextPageUrl")
+ ),
+ savedAtMillis = json.optLong("savedAtMillis", file.lastModified())
+ )
+ }.getOrElse {
+ file.delete()
+ null
+ }
+ }
+
+ fun writePage(key: String, page: CatalogPage) = synchronized(lock) {
+ runCatching {
+ val json = JSONObject().apply {
+ put("savedAtMillis", System.currentTimeMillis())
+ put("nextPageUrl", page.nextPageUrl ?: JSONObject.NULL)
+ put("books", JSONArray().apply { page.books.forEach { put(it.toJson()) } })
+ }
+ writeAtomically(
+ File(pagesDirectory, "${key.sha256()}.json"),
+ json.toString().toByteArray(Charsets.UTF_8)
+ )
+ trimPageCache()
+ }
+ }
+
+ fun readBook(key: String): QBooksBook? = synchronized(lock) {
+ val file = File(booksDirectory, "${key.sha256()}.json")
+ if (!file.isFile) return@synchronized null
+ runCatching {
+ file.setLastModified(System.currentTimeMillis())
+ JSONObject(file.readText(Charsets.UTF_8)).toBook()
+ }.getOrElse {
+ file.delete()
+ null
+ }
+ }
+
+ fun writeBook(key: String, book: QBooksBook) = synchronized(lock) {
+ runCatching {
+ writeAtomically(
+ File(booksDirectory, "${key.sha256()}.json"),
+ book.toJson().toString().toByteArray(Charsets.UTF_8)
+ )
+ trimFiles(booksDirectory, MAX_BOOK_FILES)
+ }
+ }
+
+ fun wasMetadataAttemptedRecently(key: String, maxAgeMillis: Long): Boolean = synchronized(lock) {
+ val file = File(metadataAttemptsDirectory, key.sha256())
+ file.isFile && System.currentTimeMillis() - file.lastModified() in 0..maxAgeMillis
+ }
+
+ fun markMetadataAttempt(key: String) = synchronized(lock) {
+ runCatching {
+ val file = File(metadataAttemptsDirectory, key.sha256())
+ if (!file.exists()) writeAtomically(file, byteArrayOf(1))
+ file.setLastModified(System.currentTimeMillis())
+ trimFiles(metadataAttemptsDirectory, MAX_BOOK_FILES)
+ }
+ }
+
+ fun readCover(url: String): ByteArray? = synchronized(lock) {
+ val file = File(coversDirectory, url.sha256())
+ if (!file.isFile || file.length() !in 1..MAX_COVER_FILE_BYTES) return@synchronized null
+ runCatching {
+ file.setLastModified(System.currentTimeMillis())
+ file.readBytes()
+ }.getOrNull()
+ }
+
+ fun writeCover(url: String, bytes: ByteArray) = synchronized(lock) {
+ if (bytes.isEmpty() || bytes.size > MAX_COVER_FILE_BYTES) return@synchronized
+ runCatching {
+ writeAtomically(File(coversDirectory, url.sha256()), bytes)
+ trimCoverCache()
+ }
+ }
+
+ private fun QBooksBook.toJson(): JSONObject = JSONObject().apply {
+ put("id", id)
+ put("title", title)
+ put("author", author)
+ put("format", format)
+ put("downloadUrl", downloadUrl)
+ put("shareUrl", shareUrl ?: JSONObject.NULL)
+ put("coverUrl", coverUrl ?: JSONObject.NULL)
+ put("description", description ?: JSONObject.NULL)
+ put("language", language ?: JSONObject.NULL)
+ put("publisher", publisher ?: JSONObject.NULL)
+ put("published", published ?: JSONObject.NULL)
+ }
+
+ private fun JSONObject.toBook(): QBooksBook? {
+ val id = optString("id").trim()
+ val title = optString("title").replace(TRAILING_METADATA_TAG, "").trim()
+ val downloadUrl = optString("downloadUrl").trim()
+ if (id.isBlank() || title.isBlank()) return null
+ return QBooksBook(
+ id = id,
+ title = title,
+ author = optString("author").trim().ifBlank { "Неизвестный автор" },
+ format = optString("format").trim().ifBlank { "epub" },
+ downloadUrl = downloadUrl,
+ shareUrl = nullableString("shareUrl"),
+ coverUrl = nullableString("coverUrl"),
+ description = nullableString("description"),
+ language = nullableString("language"),
+ publisher = nullableString("publisher"),
+ published = nullableString("published")
+ )
+ }
+
+ private fun JSONObject.nullableString(name: String): String? =
+ if (isNull(name)) null else optString(name).trim().ifBlank { null }
+
+ private fun writeAtomically(target: File, bytes: ByteArray) {
+ target.parentFile?.mkdirs()
+ val atomicFile = AtomicFile(target)
+ var output = atomicFile.startWrite()
+ try {
+ output.write(bytes)
+ atomicFile.finishWrite(output)
+ } catch (throwable: Throwable) {
+ atomicFile.failWrite(output)
+ throw throwable
+ }
+ }
+
+ private fun trimPageCache() {
+ trimFiles(pagesDirectory, MAX_PAGE_FILES)
+ }
+
+ private fun trimFiles(directory: File, maxFiles: Int) {
+ directory.listFiles()
+ ?.filter(File::isFile)
+ ?.sortedByDescending(File::lastModified)
+ ?.drop(maxFiles)
+ ?.forEach(File::delete)
+ }
+
+ private fun trimCoverCache() {
+ val files = coversDirectory.listFiles()
+ ?.filter(File::isFile)
+ ?.sortedBy(File::lastModified)
+ .orEmpty()
+ var totalBytes = files.sumOf(File::length)
+ var filesToRemove = (files.size - MAX_COVER_FILES).coerceAtLeast(0)
+ for (file in files) {
+ if (filesToRemove <= 0 && totalBytes <= MAX_COVER_CACHE_BYTES) break
+ totalBytes -= file.length()
+ file.delete()
+ filesToRemove--
+ }
+ }
+
+ private fun String.sha256(): String = MessageDigest.getInstance("SHA-256")
+ .digest(toByteArray(Charsets.UTF_8))
+ .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
+
+ private companion object {
+ const val MAX_PAGE_FILES = 128
+ const val MAX_BOOK_FILES = 512
+ const val MAX_COVER_FILES = 96
+ const val MAX_COVER_FILE_BYTES = 1024L * 1024L
+ const val MAX_COVER_CACHE_BYTES = 48L * 1024L * 1024L
+ val TRAILING_METADATA_TAG = Regex("\\s*\\[(?:litres(?:\\.ru)?|[a-z]{2,3})]\\s*$", RegexOption.IGNORE_CASE)
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/data/CatalogUrlResolver.kt b/app/src/main/java/com/aletheia/app/data/CatalogUrlResolver.kt
new file mode 100644
index 0000000..37bcf38
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/data/CatalogUrlResolver.kt
@@ -0,0 +1,39 @@
+package com.aletheia.app.data
+
+import java.net.URI
+
+object CatalogUrlResolver {
+ fun resolve(baseUrl: String, pathOrUrl: String): String {
+ val base = URI(baseUrl.trimEnd('/') + "/")
+ return base.resolve(pathOrUrl).toString()
+ }
+
+ fun resolveDownloadRedirect(sourceUrl: String, location: String): String {
+ val resolved = URI(sourceUrl).resolve(location.trim())
+ val normalized = if (
+ resolved.scheme.equals("http", ignoreCase = true) &&
+ resolved.host.equals(FLIBUSTA_STATIC_HOST, ignoreCase = true) &&
+ resolved.port == HTTPS_PORT
+ ) {
+ URI(
+ "https",
+ resolved.userInfo,
+ resolved.host,
+ -1,
+ resolved.path,
+ resolved.query,
+ resolved.fragment
+ )
+ } else {
+ resolved
+ }
+
+ require(normalized.scheme.equals("http", ignoreCase = true) || normalized.scheme.equals("https", ignoreCase = true)) {
+ "Каталог вернул ссылку с неподдерживаемой схемой."
+ }
+ return normalized.toString()
+ }
+
+ private const val FLIBUSTA_STATIC_HOST = "staticm.flibusta.is"
+ private const val HTTPS_PORT = 443
+}
diff --git a/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt b/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt
new file mode 100644
index 0000000..370b64a
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/data/OpdsCatalogParser.kt
@@ -0,0 +1,192 @@
+package com.aletheia.app.data
+
+import com.aletheia.app.model.QBooksBook
+import com.aletheia.app.model.CatalogPage
+import java.io.StringReader
+import java.util.Locale
+import javax.xml.parsers.DocumentBuilderFactory
+import org.w3c.dom.Document
+import org.w3c.dom.Element
+import org.xml.sax.InputSource
+
+object OpdsCatalogParser {
+ fun isOpdsFeed(xml: String): Boolean = runCatching {
+ val root = parseDocument(xml).documentElement
+ root.localName == "feed" && root.namespaceURI == ATOM_NAMESPACE
+ }.getOrDefault(false)
+
+ fun parseBooks(xml: String, resolveUrl: (String) -> String): List =
+ parsePage(xml, resolveUrl).books
+
+ fun parsePage(xml: String, resolveUrl: (String) -> String): CatalogPage {
+ val document = parseDocument(xml)
+ val root = document.documentElement
+ require(root.localName == "feed" && root.namespaceURI == ATOM_NAMESPACE) {
+ "Сервер вернул документ, который не является OPDS-каталогом."
+ }
+
+ val entries = document.getElementsByTagNameNS(ATOM_NAMESPACE, "entry")
+ val books = buildList {
+ for (index in 0 until entries.length) {
+ val entry = entries.item(index) as? Element ?: continue
+ parseBook(entry, resolveUrl)?.let(::add)
+ }
+ }
+ val nextPageUrl = root.childElements("link")
+ .firstOrNull { link ->
+ link.getAttribute("rel").split(' ').any { it.equals("next", ignoreCase = true) }
+ }
+ ?.getAttribute("href")
+ ?.trim()
+ ?.takeIf(String::isNotBlank)
+ ?.let(resolveUrl)
+ return CatalogPage(books = books, nextPageUrl = nextPageUrl)
+ }
+
+ private fun parseBook(entry: Element, resolveUrl: (String) -> String): QBooksBook? {
+ val links = entry.getElementsByTagNameNS(ATOM_NAMESPACE, "link")
+ var coverHref: String? = null
+ var shareHref: String? = null
+ val acquisitions = mutableListOf()
+
+ for (index in 0 until links.length) {
+ val link = links.item(index) as? Element ?: continue
+ val rel = link.getAttribute("rel")
+ val href = link.getAttribute("href").trim()
+ val type = link.getAttribute("type").lowercase(Locale.US)
+ if (href.isBlank()) continue
+
+ if (coverHref == null && rel in COVER_RELS) {
+ coverHref = href
+ }
+
+ val relTokens = rel.split(WHITESPACE).filter(String::isNotBlank)
+ if (
+ shareHref == null &&
+ type.substringBefore(';').trim() == "text/html" &&
+ relTokens.any { it.equals("alternate", ignoreCase = true) }
+ ) {
+ shareHref = href
+ }
+
+ if (rel.contains("acquisition", ignoreCase = true)) {
+ acquisitionFor(href, type)?.let(acquisitions::add)
+ }
+ }
+
+ val acquisition = acquisitions.minByOrNull(Acquisition::priority) ?: return null
+ val title = entry.firstText(ATOM_NAMESPACE, "title")
+ .cleanCatalogTitle()
+ .ifBlank { "Без названия" }
+ val authors = entry.getElementsByTagNameNS(ATOM_NAMESPACE, "author")
+ val authorText = buildList {
+ for (index in 0 until authors.length) {
+ val author = authors.item(index) as? Element ?: continue
+ author.firstText(ATOM_NAMESPACE, "name").takeIf(String::isNotBlank)?.let(::add)
+ }
+ }.joinToString(", ").ifBlank { "Неизвестный автор" }
+
+ val rawDescription = entry.firstText(ATOM_NAMESPACE, "content")
+ .ifBlank { entry.firstText(ATOM_NAMESPACE, "summary") }
+
+ return QBooksBook(
+ id = entry.firstText(ATOM_NAMESPACE, "id")
+ .ifBlank { resolveUrl(acquisition.href) },
+ title = title,
+ author = authorText,
+ format = acquisition.format,
+ downloadUrl = resolveUrl(acquisition.href),
+ shareUrl = shareHref?.let(resolveUrl),
+ coverUrl = coverHref?.let(resolveUrl),
+ description = rawDescription.cleanMarkup().sanitizeSourceAttribution().ifBlank { null },
+ language = entry.firstText(DC_TERMS_NAMESPACE, "language").ifBlank { null },
+ publisher = entry.firstText(DC_TERMS_NAMESPACE, "publisher")
+ .sanitizeSourceAttribution()
+ .ifBlank { null },
+ published = entry.firstText(DC_TERMS_NAMESPACE, "issued")
+ .ifBlank { entry.firstText(ATOM_NAMESPACE, "published") }
+ .ifBlank { null }
+ )
+ }
+
+ private fun acquisitionFor(href: String, type: String): Acquisition? = when {
+ type.contains("epub") -> Acquisition(href, "epub", priority = 0)
+ type == "application/x-fictionbook+xml" || type == "application/fb2" -> {
+ Acquisition(href, "fb2", priority = 1)
+ }
+ href.substringBefore('?').endsWith(".epub", ignoreCase = true) -> {
+ Acquisition(href, "epub", priority = 2)
+ }
+ href.substringBefore('?').endsWith(".fb2", ignoreCase = true) -> {
+ Acquisition(href, "fb2", priority = 3)
+ }
+ else -> null
+ }
+
+ private fun Element.firstText(namespace: String, localName: String): String =
+ getElementsByTagNameNS(namespace, localName)
+ .item(0)
+ ?.textContent
+ ?.trim()
+ .orEmpty()
+
+ private fun Element.childElements(localName: String): List = buildList {
+ val children = childNodes
+ for (index in 0 until children.length) {
+ val child = children.item(index) as? Element ?: continue
+ if (child.namespaceURI == ATOM_NAMESPACE && child.localName == localName) {
+ add(child)
+ }
+ }
+ }
+
+ private fun String.cleanMarkup(): String =
+ replace(HTML_TAG, " ")
+ .replace(WHITESPACE, " ")
+ .trim()
+
+ private fun String.sanitizeSourceAttribution(): String =
+ replace(SOURCE_URL, " ")
+ .replace(SOURCE_NAME, " ")
+ .replace(WHITESPACE, " ")
+ .trim(' ', '-', '—', ':')
+
+ private fun String.cleanCatalogTitle(): String =
+ replace(TRAILING_METADATA_TAG, "").trim()
+
+ private fun parseDocument(xml: String): Document {
+ require(!xml.contains("]+>")
+ private val WHITESPACE = Regex("\\s+")
+ private val SOURCE_URL = Regex("https?://(?:[a-z0-9-]+\\.)?flibusta\\.is\\S*", RegexOption.IGNORE_CASE)
+ private val SOURCE_NAME = Regex("flibusta|флибуста", RegexOption.IGNORE_CASE)
+ private val TRAILING_METADATA_TAG = Regex("\\s*\\[(?:litres(?:\\.ru)?|[a-z]{2,3})]\\s*$", RegexOption.IGNORE_CASE)
+}
diff --git a/app/src/main/java/com/aletheia/app/data/QBooksService.kt b/app/src/main/java/com/aletheia/app/data/QBooksService.kt
index ada84cf..4450f16 100644
--- a/app/src/main/java/com/aletheia/app/data/QBooksService.kt
+++ b/app/src/main/java/com/aletheia/app/data/QBooksService.kt
@@ -1,56 +1,73 @@
package com.aletheia.app.data
import android.util.Base64
+import com.aletheia.app.model.Book
import com.aletheia.app.model.QBooksBook
+import com.aletheia.app.model.CatalogPage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
+import java.io.ByteArrayOutputStream
import java.net.ConnectException
import java.net.HttpURLConnection
import java.net.NoRouteToHostException
+import java.net.URI
import java.net.SocketTimeoutException
import java.net.URL
import java.net.URLEncoder
import java.net.UnknownHostException
+import java.util.Collections
+import java.util.LinkedHashMap
import java.util.Locale
import java.util.UUID
import javax.net.ssl.SSLException
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
class QBooksService(
- private val settingsRepository: SettingsRepository
+ private val settingsRepository: SettingsRepository,
+ private val catalogCache: CatalogCache
) {
private var baseUrl: String? = null
private var username: String? = null
private var password: String? = null
+ private var backend: Backend = Backend.UNKNOWN
+ private val coverLimiter = Semaphore(MAX_CONCURRENT_COVERS)
+ private val metadataLimiter = Semaphore(MAX_CONCURRENT_METADATA_LOOKUPS)
+ private val coverCache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(COVER_CACHE_ENTRIES + 1, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean =
+ size > COVER_CACHE_ENTRIES
+ }
+ )
fun configure(url: String, username: String?, password: String?) {
val normalized = normalizeBaseUrl(url)
+ val publicCatalog = URI(normalized).host.equals(FLIBUSTA_HOST, ignoreCase = true)
+ val normalizedUsername = username?.trim().orEmpty().ifBlank { null }.takeUnless { publicCatalog }
+ val normalizedPassword = password?.takeIf { normalizedUsername != null }
+ if (baseUrl != normalized || this.username != normalizedUsername || this.password != normalizedPassword) {
+ backend = Backend.UNKNOWN
+ }
this.baseUrl = normalized
- this.username = username?.trim().orEmpty().ifBlank { null }
- this.password = password?.takeIf { this.username != null }
+ this.username = normalizedUsername
+ this.password = normalizedPassword
}
suspend fun testConnection(url: String, username: String?, password: String?): Result = withContext(Dispatchers.IO) {
runCatching {
configure(url, username, password)
- val healthConnection = openConnection(requireBaseUrl() + "/healthz", accept = "application/json")
- healthConnection.use { connection ->
- if (connection.responseCode in 200..299) {
- return@withContext Result.Success(Unit)
- }
- }
-
- val booksConnection = openConnection(requireBaseUrl() + "/api/books", accept = "application/json")
- booksConnection.use { connection ->
- if (connection.responseCode !in 200..299) {
- return@withContext Result.Failure(qBooksHttpError(connection.responseCode))
- }
- Result.Success(Unit)
+ when (detectBackend(forceRefresh = true)) {
+ Backend.QBOOKS,
+ Backend.OPDS -> Result.Success(Unit)
+ Backend.UNKNOWN -> Result.Failure(
+ "Сервер доступен, но не вернул поддерживаемый книжный каталог."
+ )
}
}.getOrElse { throwable ->
- Result.Failure(qBooksConnectionError("Не удалось подключиться к QBooks.", throwable), throwable)
+ Result.Failure(qBooksConnectionError("Не удалось подключиться к каталогу.", throwable), throwable)
}
}
@@ -58,43 +75,110 @@ class QBooksService(
searchQuery: String = "",
page: Int = 0,
pageSize: Int = 20
- ): Result> = withContext(Dispatchers.IO) {
+ ): Result> = when (val result = getCatalogPage(searchQuery, page, pageSize)) {
+ is Result.Success -> Result.Success(result.value.books.take(pageSize.coerceAtLeast(1)))
+ is Result.Failure -> result
+ }
+
+ suspend fun getCachedCatalogPage(
+ searchQuery: String = "",
+ page: Int = 0,
+ pageSize: Int = 20,
+ pageUrl: String? = null
+ ): CatalogCache.CachedPage? = withContext(Dispatchers.IO) {
+ configureFromSettings()
+ catalogCache.readPage(catalogCacheKey(searchQuery, page, pageUrl))?.let { cached ->
+ cached.copy(page = cached.page.copy(books = cached.page.books.take(pageSize.coerceAtLeast(1))))
+ }
+ }
+
+ suspend fun enrichBookMetadata(
+ book: QBooksBook,
+ includeDetails: Boolean = false
+ ): QBooksBook = withContext(Dispatchers.IO) {
+ configureFromSettings()
+ val cacheKey = bookCacheKey(book.id)
+ var enriched = catalogCache.readBook(cacheKey)?.let { cached -> book.mergeMissing(cached) } ?: book
+ if (!enriched.needsMetadata(includeDetails)) return@withContext enriched
+ if (catalogCache.wasMetadataAttemptedRecently(cacheKey, METADATA_RETRY_AGE_MS)) {
+ return@withContext enriched
+ }
+
+ metadataLimiter.withPermit {
+ enriched = catalogCache.readBook(cacheKey)?.let { cached -> enriched.mergeMissing(cached) } ?: enriched
+ if (!enriched.needsMetadata(includeDetails) ||
+ catalogCache.wasMetadataAttemptedRecently(cacheKey, METADATA_RETRY_AGE_MS)
+ ) {
+ return@withPermit enriched
+ }
+
+ catalogCache.markMetadataAttempt(cacheKey)
+ val query = enriched.title.ifBlank { enriched.author }.trim()
+ if (query.isBlank()) return@withPermit enriched
+ val match = when (val result = getCatalogPage(query, pageSize = DEFAULT_PAGE_SIZE)) {
+ is Result.Success -> findMetadataMatch(enriched, result.value.books)
+ is Result.Failure -> null
+ }
+ if (match != null) enriched = enriched.mergeMissing(match)
+ catalogCache.writeBook(cacheKey, enriched)
+ enriched
+ }
+ }
+
+ suspend fun enrichLocalBook(book: Book): Book = withContext(Dispatchers.IO) {
+ if (!book.needsCatalogMetadata()) return@withContext book
+ configureFromSettings()
+ val synthetic = QBooksBook(
+ id = book.remoteId ?: "local:${book.fileName}:${book.title}",
+ title = book.title,
+ author = book.author,
+ format = book.format,
+ downloadUrl = ""
+ )
+ var metadata = book.remoteId
+ ?.let { catalogCache.readBook(bookCacheKey(it)) }
+ ?.let { cached -> synthetic.mergeMissing(cached) }
+ ?: synthetic
+ metadata = enrichBookMetadata(metadata)
+ val cover = book.coverImage ?: metadata.coverImage ?: fetchCoverImage(metadata)
+ book.copy(
+ title = book.title.ifBlank { metadata.title },
+ author = if (book.author.isUnknownAuthor()) metadata.author else book.author,
+ coverImage = cover
+ )
+ }
+
+ suspend fun getCatalogPage(
+ searchQuery: String = "",
+ page: Int = 0,
+ pageSize: Int = 20,
+ pageUrl: String? = null
+ ): Result = withContext(Dispatchers.IO) {
runCatching {
configureFromSettings()
val query = searchQuery.trim()
- val url = buildString {
- append(requireBaseUrl())
- append("/api/books")
- if (query.isNotEmpty()) {
- append("?q=")
- append(URLEncoder.encode(query, Charsets.UTF_8.name()))
+ val catalogPage = when (detectBackend()) {
+ Backend.QBOOKS -> {
+ val books = getBooksFromQBooks(query, page, maxOf(pageSize, DEFAULT_PAGE_SIZE))
+ CatalogPage(
+ books = books,
+ nextPageUrl = if (books.size >= pageSize) "qbooks-page:${page + 1}" else null
+ )
+ }
+ Backend.OPDS -> getBooksFromOpds(query, page, pageSize, pageUrl)
+ Backend.UNKNOWN -> error(
+ "Сервер не поддерживает книжный каталог. Проверьте адрес каталога."
+ )
+ }
+ runCatching {
+ catalogCache.writePage(catalogCacheKey(query, page, pageUrl), catalogPage)
+ catalogPage.books.forEach { book ->
+ catalogCache.writeBook(bookCacheKey(book.id), book)
}
}
- val connection = openConnection(url, accept = "application/json")
- connection.use {
- if (it.responseCode !in 200..299) {
- return@withContext Result.Failure(qBooksHttpError(it.responseCode))
- }
-
- val payload = it.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() }
- val books = JSONObject(payload)
- .optJSONArray("books")
- ?.let { array ->
- (0 until array.length()).mapNotNull { index ->
- array.optJSONObject(index)?.toBook()
- }
- }
- .orEmpty()
- .drop((page.coerceAtLeast(0)) * pageSize)
- .take(pageSize)
- .map { book ->
- book.copy(coverImage = fetchCoverImageInternal(book.coverUrl))
- }
-
- Result.Success(books)
- }
+ Result.Success(catalogPage)
}.getOrElse { throwable ->
- Result.Failure(qBooksConnectionError("Не удалось загрузить каталог QBooks.", throwable), throwable)
+ Result.Failure(qBooksConnectionError("Не удалось загрузить каталог.", throwable), throwable)
}
}
@@ -103,6 +187,7 @@ class QBooksService(
booksDirectory: File,
progress: (Int) -> Unit
): Result = withContext(Dispatchers.IO) {
+ var destination: File? = null
runCatching {
configureFromSettings()
if (!booksDirectory.exists()) {
@@ -110,8 +195,9 @@ class QBooksService(
}
val extension = book.format.ifBlank { "epub" }.lowercase(Locale.US)
- val destination = File(booksDirectory, "${UUID.randomUUID()}.$extension")
- val connection = openConnection(book.downloadUrl)
+ val target = File(booksDirectory, "${UUID.randomUUID()}.$extension.part")
+ destination = target
+ val connection = openDownloadConnection(book.downloadUrl)
connection.use {
if (it.responseCode !in 200..299) {
return@withContext Result.Failure(qBooksHttpError(it.responseCode))
@@ -120,7 +206,7 @@ class QBooksService(
val contentLength = it.contentLengthLong
var downloaded = 0L
it.inputStream.use { input ->
- FileOutputStream(destination).use { output ->
+ FileOutputStream(target).use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
@@ -134,34 +220,75 @@ class QBooksService(
}
}
}
+ require(target.length() > 0L) { "Каталог вернул пустой файл книги." }
progress(100)
- Result.Success(destination)
+ Result.Success(target)
}.getOrElse { throwable ->
- Result.Failure(qBooksConnectionError("Не удалось скачать книгу из QBooks.", throwable), throwable)
+ destination?.takeIf(File::exists)?.delete()
+ Result.Failure(qBooksConnectionError("Не удалось скачать книгу.", throwable), throwable)
}
}
suspend fun fetchCoverImage(book: QBooksBook): ByteArray? = withContext(Dispatchers.IO) {
configureFromSettings()
- fetchCoverImageInternal(book.coverUrl)
+ coverLimiter.withPermit { fetchCoverImageInternal(book.coverUrl) }
}
private fun fetchCoverImageInternal(coverUrl: String?): ByteArray? {
coverUrl ?: return null
+ coverCache[coverUrl]?.let { return it }
+ catalogCache.readCover(coverUrl)?.let { cached ->
+ coverCache[coverUrl] = cached
+ return cached
+ }
return runCatching {
- val connection = openConnection(coverUrl)
+ val connection = openConnection(coverUrl).apply {
+ connectTimeout = COVER_TIMEOUT_MS
+ readTimeout = COVER_TIMEOUT_MS
+ }
connection.use {
if (it.responseCode !in 200..299) return null
- it.inputStream.use { stream -> stream.readBytes() }
+ if (it.contentLengthLong > MAX_COVER_BYTES) return null
+ val image = it.inputStream.use { stream ->
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var remaining = MAX_COVER_BYTES + 1
+ while (remaining > 0) {
+ val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
+ if (read <= 0) break
+ output.write(buffer, 0, read)
+ remaining -= read
+ }
+ output.toByteArray()
+ }
+ image.takeIf { it.isNotEmpty() && it.size <= MAX_COVER_BYTES }
+ ?.also {
+ coverCache[coverUrl] = it
+ catalogCache.writeCover(coverUrl, it)
+ }
}
}.getOrNull()
}
+ private fun openDownloadConnection(url: String): HttpURLConnection {
+ var currentUrl = url
+ repeat(MAX_DOWNLOAD_REDIRECTS + 1) { redirectCount ->
+ val connection = openConnection(currentUrl, followRedirects = false)
+ if (connection.responseCode !in REDIRECT_RESPONSE_CODES) {
+ return connection
+ }
+
+ val location = connection.getHeaderField("Location")
+ connection.disconnect()
+ require(!location.isNullOrBlank()) { "Каталог вернул перенаправление без адреса назначения." }
+ require(redirectCount < MAX_DOWNLOAD_REDIRECTS) { "Слишком много перенаправлений при скачивании книги." }
+ currentUrl = CatalogUrlResolver.resolveDownloadRedirect(currentUrl, location)
+ }
+ error("Не удалось открыть ссылку скачивания книги.")
+ }
+
private fun configureFromSettings() {
- val url = settingsRepository.getString(
- SettingsRepository.KEY_QBOOKS_URL,
- settingsRepository.getString(SettingsRepository.KEY_LEGACY_CATALOG_URL).orEmpty()
- ).orEmpty()
+ val url = settingsRepository.getCatalogUrl()
val user = settingsRepository.getString(
SettingsRepository.KEY_QBOOKS_USERNAME,
settingsRepository.getString(SettingsRepository.KEY_LEGACY_CATALOG_USERNAME).orEmpty()
@@ -170,16 +297,125 @@ class QBooksService(
configure(url, user, pass)
}
+ private fun detectBackend(forceRefresh: Boolean = false): Backend {
+ if (!forceRefresh && backend != Backend.UNKNOWN) {
+ return backend
+ }
+
+ val preferOpds = runCatching {
+ URI(requireBaseUrl()).path.trimEnd('/').endsWith("/opds", ignoreCase = true)
+ }.getOrDefault(false)
+
+ backend = if (preferOpds) {
+ when {
+ probeOpds() -> Backend.OPDS
+ probeQBooks() -> Backend.QBOOKS
+ else -> Backend.UNKNOWN
+ }
+ } else {
+ when {
+ probeQBooks() -> Backend.QBOOKS
+ probeOpds() -> Backend.OPDS
+ else -> Backend.UNKNOWN
+ }
+ }
+ return backend
+ }
+
+ private fun probeOpds(): Boolean = runCatching {
+ openConnection(requireBaseUrl(), accept = OPDS_ACCEPT).use { connection ->
+ if (connection.responseCode !in 200..299) {
+ return@use false
+ }
+ val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
+ OpdsCatalogParser.isOpdsFeed(payload)
+ }
+ }.getOrDefault(false)
+
+ private fun probeQBooks(): Boolean =
+ probeSuccessfulResponse(requireBaseUrl() + "/healthz", "application/json") ||
+ probeSuccessfulResponse(requireBaseUrl() + "/api/books", "application/json")
+
+ private fun probeSuccessfulResponse(url: String, accept: String): Boolean = runCatching {
+ openConnection(url, accept).use { connection -> connection.responseCode in 200..299 }
+ }.getOrDefault(false)
+
+ private fun getBooksFromQBooks(query: String, page: Int, pageSize: Int): List {
+ val url = buildString {
+ append(requireBaseUrl())
+ append("/api/books")
+ if (query.isNotEmpty()) {
+ append("?q=")
+ append(URLEncoder.encode(query, Charsets.UTF_8.name()))
+ }
+ }
+ val books = openConnection(url, accept = "application/json").use { connection ->
+ if (connection.responseCode !in 200..299) {
+ error(qBooksHttpError(connection.responseCode))
+ }
+
+ val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
+ JSONObject(payload)
+ .optJSONArray("books")
+ ?.let { array ->
+ (0 until array.length()).mapNotNull { index ->
+ array.optJSONObject(index)?.toBook()
+ }
+ }
+ .orEmpty()
+ .drop(page.coerceAtLeast(0) * pageSize.coerceAtLeast(1))
+ .take(pageSize.coerceAtLeast(1))
+ }
+ return books
+ }
+
+ private fun getBooksFromOpds(
+ query: String,
+ page: Int,
+ pageSize: Int,
+ pageUrl: String?
+ ): CatalogPage {
+ val url = pageUrl?.takeIf(String::isNotBlank) ?: buildOpdsBooksUrl(query, page.coerceAtLeast(0))
+ val parsed = openConnection(url, accept = OPDS_ACCEPT).use { connection ->
+ if (connection.responseCode !in 200..299) {
+ error(qBooksHttpError(connection.responseCode))
+ }
+
+ val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
+ OpdsCatalogParser.parsePage(payload) { pathOrUrl ->
+ resolveCatalogLink(url, pathOrUrl)
+ }
+ }
+ return parsed
+ }
+
+ private fun buildOpdsBooksUrl(query: String, page: Int): String {
+ val root = requireBaseUrl().trimEnd('/')
+ val uri = URI(root)
+ val isFlibusta = uri.host.equals(FLIBUSTA_HOST, ignoreCase = true) &&
+ uri.path.trimEnd('/').equals("/opds", ignoreCase = true)
+ val encodedQuery = URLEncoder.encode(query, Charsets.UTF_8.name())
+
+ return when {
+ isFlibusta && query.isBlank() -> "$root/new/$page/new"
+ isFlibusta -> "$root/search?searchType=books&searchTerm=$encodedQuery&pageNumber=$page"
+ query.isBlank() -> root
+ else -> "$root/search?searchTerm=$encodedQuery"
+ }
+ }
+
private fun JSONObject.toBook(): QBooksBook {
val id = optLong("id", -1L)
val format = optString("format", "").ifBlank { "EPUB" }.lowercase(Locale.US)
val cover = optString("cover_src").ifBlank { optString("cover_url") }.ifBlank { null }
+ val share = optString("share_url").ifBlank { optString("web_url") }.ifBlank { null }
return QBooksBook(
id = id.toString(),
title = optString("title").ifBlank { optString("rel_path").ifBlank { "Без названия" } },
author = authorsText(),
format = format,
downloadUrl = absoluteUrl("/book/$id/download"),
+ shareUrl = share?.let(::absoluteUrl),
coverUrl = cover?.let(::absoluteUrl),
description = optString("description").ifBlank { null },
language = optString("language").ifBlank { null },
@@ -196,17 +432,23 @@ class QBooksService(
.ifBlank { "Неизвестный автор" }
}
- private fun openConnection(url: String, accept: String = "*/*"): HttpURLConnection {
+ private fun openConnection(
+ url: String,
+ accept: String = "*/*",
+ followRedirects: Boolean = true
+ ): HttpURLConnection {
return (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
- instanceFollowRedirects = true
+ instanceFollowRedirects = followRedirects
requestMethod = "GET"
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", accept)
val user = username
val pass = password
- if (!user.isNullOrBlank()) {
+ val requestHost = runCatching { URI(url).host }.getOrNull()
+ val configuredHost = runCatching { URI(requireBaseUrl()).host }.getOrNull()
+ if (!user.isNullOrBlank() && requestHost.equals(configuredHost, ignoreCase = true)) {
val credentials = "$user:${pass.orEmpty()}"
val encoded = Base64.encodeToString(credentials.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
setRequestProperty("Authorization", "Basic $encoded")
@@ -222,26 +464,88 @@ class QBooksService(
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
return pathOrUrl
}
- val path = if (pathOrUrl.startsWith("/")) pathOrUrl else "/$pathOrUrl"
- return requireBaseUrl() + path
+ return CatalogUrlResolver.resolve(requireBaseUrl(), pathOrUrl)
}
- private fun requireBaseUrl(): String = baseUrl ?: error("QBooks не настроен")
+ private fun resolveCatalogLink(feedUrl: String, pathOrUrl: String): String {
+ if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
+ return pathOrUrl
+ }
+ return URI(feedUrl).resolve(pathOrUrl).toString()
+ }
+
+ private fun catalogCacheKey(searchQuery: String, page: Int, pageUrl: String?): String =
+ listOf(
+ requireBaseUrl(),
+ username.orEmpty(),
+ searchQuery.trim().lowercase(Locale.ROOT),
+ page.coerceAtLeast(0).toString(),
+ pageUrl.orEmpty()
+ ).joinToString("\u001f")
+
+ private fun bookCacheKey(bookId: String): String =
+ "${requireBaseUrl()}\u001f${username.orEmpty()}\u001f${bookId.trim()}"
+
+ private fun findMetadataMatch(source: QBooksBook, candidates: List): QBooksBook? {
+ candidates.firstOrNull { it.id == source.id }?.let { return it }
+ val normalizedTitle = source.title.normalizedMetadataText()
+ if (normalizedTitle.isBlank()) return null
+ return candidates
+ .filter { it.title.normalizedMetadataText() == normalizedTitle }
+ .maxByOrNull { candidate ->
+ if (!source.author.isUnknownAuthor() &&
+ candidate.author.normalizedMetadataText() == source.author.normalizedMetadataText()
+ ) 2 else 1
+ }
+ }
+
+ private fun QBooksBook.mergeMissing(candidate: QBooksBook): QBooksBook = copy(
+ title = title.ifBlank { candidate.title },
+ author = if (author.isUnknownAuthor()) candidate.author else author,
+ shareUrl = shareUrl ?: candidate.shareUrl,
+ coverUrl = coverUrl ?: candidate.coverUrl,
+ coverImage = coverImage ?: candidate.coverImage,
+ description = description?.takeIf(String::isNotBlank) ?: candidate.description,
+ language = language?.takeIf(String::isNotBlank) ?: candidate.language,
+ publisher = publisher?.takeIf(String::isNotBlank) ?: candidate.publisher,
+ published = published?.takeIf(String::isNotBlank) ?: candidate.published
+ )
+
+ private fun QBooksBook.needsMetadata(includeDetails: Boolean): Boolean =
+ title.isBlank() || author.isUnknownAuthor() || coverUrl.isNullOrBlank() ||
+ (includeDetails && (
+ description.isNullOrBlank() || language.isNullOrBlank() || published.isNullOrBlank() ||
+ shareUrl.isNullOrBlank()
+ ))
+
+ private fun Book.needsCatalogMetadata(): Boolean =
+ title.isBlank() || author.isUnknownAuthor() || coverImage == null
+
+ private fun String.isUnknownAuthor(): Boolean =
+ trim().lowercase(Locale.ROOT) in UNKNOWN_AUTHORS
+
+ private fun String.normalizedMetadataText(): String =
+ lowercase(Locale.ROOT)
+ .replace(NON_METADATA_CHARACTER, " ")
+ .replace(METADATA_WHITESPACE, " ")
+ .trim()
+
+ private fun requireBaseUrl(): String = baseUrl ?: error("Каталог не настроен")
private fun qBooksHttpError(responseCode: Int): String =
when (responseCode) {
HttpURLConnection.HTTP_UNAUTHORIZED,
HttpURLConnection.HTTP_FORBIDDEN -> {
- "QBooks не принял логин или пароль. Проверьте доступ в настройках."
+ "Каталог не принял логин или пароль. Проверьте доступ в настройках."
}
HttpURLConnection.HTTP_NOT_FOUND -> {
- "QBooks доступен, но нужный API не найден. Проверьте адрес сервера."
+ "Каталог доступен, но нужный API не найден. Проверьте адрес сервера."
}
in 500..599 -> {
- "QBooks временно недоступен (HTTP $responseCode). Попробуйте позже."
+ "Каталог временно недоступен (HTTP $responseCode). Попробуйте позже."
}
else -> {
- "QBooks вернул HTTP $responseCode. Проверьте сервер или повторите попытку."
+ "Каталог вернул HTTP $responseCode. Проверьте сервер или повторите попытку."
}
}
@@ -249,24 +553,24 @@ class QBooksService(
val root = throwable.rootCause()
val hint = when (root) {
is UnknownHostException -> {
- "Сервер QBooks не найден. Проверьте адрес в настройках."
+ "Сервер каталога не найден. Проверьте подключение к интернету."
}
is SocketTimeoutException -> {
- "Сервер QBooks не ответил вовремя. Проверьте сеть или повторите попытку."
+ "Сервер каталога не ответил вовремя. Проверьте сеть или повторите попытку."
}
is ConnectException,
is NoRouteToHostException -> {
- "Не удалось открыть соединение с QBooks. Проверьте сеть и адрес сервера."
+ "Не удалось открыть соединение с каталогом. Проверьте сеть."
}
is SSLException -> {
- "HTTPS-соединение с QBooks не прошло проверку. Проверьте сертификат или адрес."
+ "Защищённое соединение с каталогом не прошло проверку."
}
is IllegalArgumentException,
is IllegalStateException -> {
- root.message?.takeIf { it.isNotBlank() } ?: "Проверьте адрес QBooks в настройках."
+ root.message?.takeIf { it.isNotBlank() } ?: "Проверьте настройки каталога."
}
else -> {
- "Проверьте адрес, сеть и доступ к QBooks."
+ "Проверьте сеть и доступ к каталогу."
}
}
return "$action $hint"
@@ -285,8 +589,28 @@ class QBooksService(
}
}
+ private enum class Backend {
+ UNKNOWN,
+ QBOOKS,
+ OPDS
+ }
+
private companion object {
private const val TIMEOUT_MS = 30_000
- private const val USER_AGENT = "Aletheia-QBooks/1.0"
+ private const val COVER_TIMEOUT_MS = 5_000
+ private const val MAX_COVER_BYTES = 1024 * 1024
+ private const val MAX_CONCURRENT_COVERS = 4
+ private const val COVER_CACHE_ENTRIES = 24
+ private const val DEFAULT_PAGE_SIZE = 20
+ private const val MAX_CONCURRENT_METADATA_LOOKUPS = 2
+ private const val METADATA_RETRY_AGE_MS = 7 * 24 * 60 * 60 * 1000L
+ private const val USER_AGENT = "Aletheia/2.28"
+ private const val OPDS_ACCEPT = "application/atom+xml, application/xml;q=0.9, */*;q=0.8"
+ private const val FLIBUSTA_HOST = "m.flibusta.is"
+ private const val MAX_DOWNLOAD_REDIRECTS = 5
+ private val REDIRECT_RESPONSE_CODES = setOf(301, 302, 303, 307, 308)
+ private val UNKNOWN_AUTHORS = setOf("", "unknown", "unknown author", "неизвестный автор")
+ private val NON_METADATA_CHARACTER = Regex("[^\\p{L}\\p{N}]+")
+ private val METADATA_WHITESPACE = Regex("\\s+")
}
}
diff --git a/app/src/main/java/com/aletheia/app/data/QBooksUrlPolicy.kt b/app/src/main/java/com/aletheia/app/data/QBooksUrlPolicy.kt
index 8973bf2..7bd6aa0 100644
--- a/app/src/main/java/com/aletheia/app/data/QBooksUrlPolicy.kt
+++ b/app/src/main/java/com/aletheia/app/data/QBooksUrlPolicy.kt
@@ -8,7 +8,9 @@ object QBooksUrlPolicy {
"localhost",
"127.0.0.1",
"10.0.2.2",
- "192.168.0.185"
+ "192.168.0.185",
+ "m.flibusta.is",
+ "staticm.flibusta.is"
)
fun validate(url: String): Validation {
@@ -75,10 +77,10 @@ object QBooksUrlPolicy {
val errorMessage: String
get() = when (reason) {
Reason.Allowed -> ""
- Reason.Blank -> "Адрес QBooks не задан"
- Reason.InvalidUri -> "Адрес QBooks указан некорректно"
- Reason.UnsupportedScheme -> "Адрес QBooks должен начинаться с http:// или https://"
- Reason.ExternalCleartext -> "HTTP разрешен только для локального QBooks: localhost, 10.0.2.2, 192.168.0.185 или *.local. Для внешнего сервера используйте HTTPS."
+ Reason.Blank -> "Адрес каталога не задан"
+ Reason.InvalidUri -> "Адрес каталога указан некорректно"
+ Reason.UnsupportedScheme -> "Адрес каталога должен начинаться с http:// или https://"
+ Reason.ExternalCleartext -> "Для внешнего каталога используйте HTTPS. HTTP разрешён только в локальной сети."
}
}
diff --git a/app/src/main/java/com/aletheia/app/data/SettingsRepository.kt b/app/src/main/java/com/aletheia/app/data/SettingsRepository.kt
index 166ee2c..3748097 100644
--- a/app/src/main/java/com/aletheia/app/data/SettingsRepository.kt
+++ b/app/src/main/java/com/aletheia/app/data/SettingsRepository.kt
@@ -31,6 +31,19 @@ class SettingsRepository(
databaseHelper.setSetting(key, value.toString())
}
+ fun getBoolean(key: String, defaultValue: Boolean = false): Boolean =
+ databaseHelper.getSetting(key)?.toBooleanStrictOrNull() ?: defaultValue
+
+ fun setBoolean(key: String, value: Boolean) {
+ databaseHelper.setSetting(key, value.toString())
+ }
+
+ fun setAll(values: Map) {
+ databaseHelper.setSettings(values)
+ }
+
+ fun getAll(keys: Collection): Map = databaseHelper.getSettings(keys)
+
fun getSecurePassword(): String =
securePrefs.getString(
KEY_QBOOKS_PASSWORD,
@@ -41,6 +54,12 @@ class SettingsRepository(
securePrefs.edit().putString(KEY_QBOOKS_PASSWORD, password).apply()
}
+ fun getCatalogUrl(): String {
+ val configured = databaseHelper.getSetting(KEY_QBOOKS_URL)
+ ?: databaseHelper.getSetting(KEY_LEGACY_CATALOG_URL)
+ return resolveCatalogUrl(configured)
+ }
+
companion object {
const val KEY_QBOOKS_URL = "QBooksUrl"
const val KEY_QBOOKS_USERNAME = "QBooksUsername"
@@ -50,9 +69,38 @@ class SettingsRepository(
const val KEY_DEFAULT_FONT_FAMILY = "DefaultFontFamily"
const val KEY_THEME = "Theme"
const val KEY_BRIGHTNESS = "Brightness"
+ const val KEY_READER_LINE_HEIGHT = "ReaderLineHeight"
+ const val KEY_READER_MARGIN = "ReaderMargin"
+ const val KEY_READER_ALIGNMENT = "ReaderAlignment"
+ const val KEY_READER_VERTICAL_SCROLL = "ReaderVerticalScroll"
+ const val KEY_READER_PAGE_TURN_MODE = "ReaderPageTurnMode"
+ const val KEY_READER_INVERT_ZONES = "ReaderInvertZones"
+ const val KEY_READER_BRIGHTNESS_GESTURE = "ReaderBrightnessGesture"
+ const val KEY_READER_SYSTEM_BRIGHTNESS = "ReaderSystemBrightness"
+ const val KEY_READER_ORIENTATION = "ReaderOrientation"
+ const val KEY_READER_VOLUME_BUTTONS = "ReaderVolumeButtons"
+ const val KEY_READER_KEEP_SCREEN_ON = "ReaderKeepScreenOn"
+ const val KEY_READER_SHOW_TITLE = "ReaderShowTitle"
+ const val KEY_READER_SHOW_STATUS = "ReaderShowStatus"
+ const val DEFAULT_CATALOG_URL = "http://m.flibusta.is/opds"
+
+ internal fun resolveCatalogUrl(configured: String?): String {
+ val normalized = configured.orEmpty().trim().trimEnd('/')
+ return if (
+ normalized.isBlank() ||
+ normalized.equals(LEGACY_QBOOKS_URL, ignoreCase = true) ||
+ normalized.equals(LEGACY_HTTPS_CATALOG_URL, ignoreCase = true)
+ ) {
+ DEFAULT_CATALOG_URL
+ } else {
+ normalized
+ }
+ }
private const val PASSWORD_PREFS = "aletheia_passwords"
private const val KEY_QBOOKS_PASSWORD = "qbooks_password_secure"
private const val KEY_LEGACY_CATALOG_PASSWORD = "calibre_password_secure"
+ private const val LEGACY_QBOOKS_URL = "https://qbooks.kusoft.xyz"
+ private const val LEGACY_HTTPS_CATALOG_URL = "https://m.flibusta.is/opds"
}
}
diff --git a/app/src/main/java/com/aletheia/app/model/CatalogPage.kt b/app/src/main/java/com/aletheia/app/model/CatalogPage.kt
new file mode 100644
index 0000000..6b37588
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/model/CatalogPage.kt
@@ -0,0 +1,6 @@
+package com.aletheia.app.model
+
+data class CatalogPage(
+ val books: List,
+ val nextPageUrl: String? = null
+)
diff --git a/app/src/main/java/com/aletheia/app/model/QBooksBook.kt b/app/src/main/java/com/aletheia/app/model/QBooksBook.kt
index 9966c42..2bcbc64 100644
--- a/app/src/main/java/com/aletheia/app/model/QBooksBook.kt
+++ b/app/src/main/java/com/aletheia/app/model/QBooksBook.kt
@@ -6,10 +6,11 @@ data class QBooksBook(
val author: String,
val format: String,
val downloadUrl: String,
+ val shareUrl: String? = null,
val coverUrl: String? = null,
val coverImage: ByteArray? = null,
val description: String? = null,
val language: String? = null,
val publisher: String? = null,
val published: String? = null
-)
+) : java.io.Serializable
diff --git a/app/src/main/java/com/aletheia/app/model/ReadingNote.kt b/app/src/main/java/com/aletheia/app/model/ReadingNote.kt
index e20bd25..265c681 100644
--- a/app/src/main/java/com/aletheia/app/model/ReadingNote.kt
+++ b/app/src/main/java/com/aletheia/app/model/ReadingNote.kt
@@ -10,8 +10,15 @@ data class ReadingNote(
val chapterTitle: String?,
val selectedText: String?,
val noteText: String,
- val createdAt: Long = System.currentTimeMillis()
+ val createdAt: Long = System.currentTimeMillis(),
+ val highlightColor: String? = null,
+ val kind: String = KIND_NOTE
) {
val progressPercent: Int
get() = (progress * 100).toInt().coerceIn(0, 100)
+
+ companion object {
+ const val KIND_NOTE = "note"
+ const val KIND_QUOTE = "quote"
+ }
}
diff --git a/app/src/main/java/com/aletheia/app/ui/books/BooksAdapter.kt b/app/src/main/java/com/aletheia/app/ui/books/BooksAdapter.kt
index faa55fc..91f151a 100644
--- a/app/src/main/java/com/aletheia/app/ui/books/BooksAdapter.kt
+++ b/app/src/main/java/com/aletheia/app/ui/books/BooksAdapter.kt
@@ -1,6 +1,7 @@
package com.aletheia.app.ui.books
import android.view.LayoutInflater
+import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
@@ -12,8 +13,10 @@ import com.aletheia.app.util.setBookCover
class BooksAdapter(
private val onBookClicked: (Book) -> Unit,
- private val onBookLongPressed: (Book) -> Unit,
- private val onAddBookClicked: () -> Unit
+ private val onAddBookClicked: () -> Unit,
+ private val onBookLongPressed: ((Book) -> Unit)? = null,
+ private val onBookActionsClicked: ((View, Book) -> Unit)? = null,
+ private val includeAddItem: Boolean = true
) : RecyclerView.Adapter() {
private val items = mutableListOf()
@@ -54,6 +57,13 @@ class BooksAdapter(
diff.dispatchUpdatesTo(this)
}
+ fun updateBook(book: Book) {
+ val index = items.indexOfFirst { it.id == book.id }
+ if (index < 0) return
+ items[index] = book
+ notifyItemChanged(index)
+ }
+
override fun getItemViewType(position: Int): Int =
if (position < items.size) VIEW_TYPE_BOOK else VIEW_TYPE_ADD
@@ -73,7 +83,7 @@ class BooksAdapter(
}
}
- override fun getItemCount(): Int = if (items.isEmpty()) 0 else items.size + 1
+ override fun getItemCount(): Int = items.shelfItemCount()
override fun getItemId(position: Int): Long =
if (position < items.size) items[position].id else ADD_ITEM_ID
@@ -93,9 +103,24 @@ class BooksAdapter(
book.progressText
)
binding.root.setOnClickListener { onBookClicked(book) }
- binding.root.setOnLongClickListener {
- onBookLongPressed(book)
- true
+ if (onBookLongPressed == null) {
+ binding.root.setOnLongClickListener(null)
+ binding.root.isLongClickable = false
+ } else {
+ binding.root.setOnLongClickListener {
+ onBookLongPressed.invoke(book)
+ true
+ }
+ }
+ if (onBookActionsClicked == null) {
+ binding.bookActions.visibility = View.GONE
+ binding.bookActions.setOnClickListener(null)
+ } else {
+ binding.bookActions.visibility = View.VISIBLE
+ binding.bookActions.contentDescription = context.getString(R.string.a11y_book_actions, book.title)
+ binding.bookActions.setOnClickListener { anchor ->
+ onBookActionsClicked.invoke(anchor, book)
+ }
}
}
}
@@ -121,7 +146,8 @@ class BooksAdapter(
else -> contentEquals(other)
}
- private fun List.shelfItemCount(): Int = if (isEmpty()) 0 else size + 1
+ private fun List.shelfItemCount(): Int =
+ if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
private companion object {
const val VIEW_TYPE_BOOK = 1
diff --git a/app/src/main/java/com/aletheia/app/ui/books/BookshelfFragment.kt b/app/src/main/java/com/aletheia/app/ui/books/BookshelfFragment.kt
index f224c02..4b209e1 100644
--- a/app/src/main/java/com/aletheia/app/ui/books/BookshelfFragment.kt
+++ b/app/src/main/java/com/aletheia/app/ui/books/BookshelfFragment.kt
@@ -12,6 +12,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@@ -24,10 +25,13 @@ import com.aletheia.app.databinding.FragmentBookshelfBinding
import com.aletheia.app.model.Book
import com.aletheia.app.model.QBooksBook
import com.aletheia.app.ui.main.MainActivity
+import com.aletheia.app.ui.qbooks.BookDetailActivity
import com.aletheia.app.ui.reader.ReaderActivity
+import com.aletheia.app.util.BookSharing
import com.aletheia.app.util.setBookCover
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@@ -43,6 +47,9 @@ class BookshelfFragment : Fragment() {
private var currentBooks: List = emptyList()
private var visibleBooks: List = emptyList()
private var continueReadingBook: Book? = null
+ private val metadataJobs = mutableListOf()
+ private var loadBooksJob: Job? = null
+ private var libraryFilter = LibraryFilter.ALL
private val importBookLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) {
@@ -76,8 +83,9 @@ class BookshelfFragment : Fragment() {
booksAdapter = BooksAdapter(
onBookClicked = ::openBook,
+ onAddBookClicked = ::launchImport,
onBookLongPressed = ::confirmDeleteBook,
- onAddBookClicked = ::launchImport
+ onBookActionsClicked = ::showBookActions
)
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
@@ -90,6 +98,7 @@ class BookshelfFragment : Fragment() {
binding.qbooksHomeRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
binding.qbooksHomeRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
binding.qbooksHomeRecycler.adapter = qBooksShelfAdapter
+ binding.qbooksHomeSection.visibility = View.GONE
qBooksShelfAdapter.submitStatus(
getString(R.string.qbooks_home_loading_title),
getString(R.string.qbooks_home_loading_subtitle)
@@ -118,21 +127,48 @@ class BookshelfFragment : Fragment() {
binding.clearLibrarySearchButton.setOnClickListener {
binding.booksSearchInput.setText("")
}
+ binding.libraryFilterGroup.setOnCheckedStateChangeListener { _, checkedIds ->
+ libraryFilter = when (checkedIds.firstOrNull()) {
+ R.id.library_filter_reading -> LibraryFilter.READING
+ R.id.library_filter_downloaded -> LibraryFilter.DOWNLOADED
+ R.id.library_filter_finished -> LibraryFilter.FINISHED
+ else -> LibraryFilter.ALL
+ }
+ renderFilteredBooks()
+ }
+ binding.emptyLibraryCard.setOnClickListener { launchImport() }
+ parentFragmentManager.setFragmentResultListener(
+ REQUEST_REFRESH_LIBRARY,
+ viewLifecycleOwner
+ ) { _, _ ->
+ loadBooks()
+ }
}
override fun onResume() {
super.onResume()
loadBooks()
- loadQBooksPreview()
+ }
+
+ override fun onHiddenChanged(hidden: Boolean) {
+ super.onHiddenChanged(hidden)
+ if (!hidden && _binding != null) {
+ loadBooks()
+ }
}
override fun onDestroyView() {
+ loadBooksJob?.cancel()
+ loadBooksJob = null
+ metadataJobs.forEach { it.cancel() }
+ metadataJobs.clear()
super.onDestroyView()
_binding = null
}
private fun loadBooks() {
- lifecycleScope.launch(Dispatchers.IO) {
+ loadBooksJob?.cancel()
+ loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val result = runCatching { app.bookRepository.getAllBooks() }
withContext(Dispatchers.Main) {
if (_binding == null) {
@@ -142,6 +178,7 @@ class BookshelfFragment : Fragment() {
result.onSuccess { books ->
currentBooks = books
renderFilteredBooks()
+ enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
}.onFailure { exception ->
showMessage(getString(R.string.dialog_load_books_failed, exception.readableMessage()))
}
@@ -149,6 +186,22 @@ class BookshelfFragment : Fragment() {
}
}
+ private fun enrichMissingMetadata(books: List) {
+ metadataJobs.forEach { it.cancel() }
+ metadataJobs.clear()
+ books.forEach { book ->
+ metadataJobs += viewLifecycleOwner.lifecycleScope.launch {
+ val enriched = app.qBooksService.enrichLocalBook(book)
+ if (enriched == book || _binding == null) return@launch
+ withContext(Dispatchers.IO) { app.bookRepository.updateBook(enriched) }
+ currentBooks = currentBooks.map { current ->
+ if (current.id == enriched.id) enriched else current
+ }
+ renderFilteredBooks()
+ }
+ }
+ }
+
private fun renderFilteredBooks() {
val query = binding.booksSearchInput.text?.toString().orEmpty().trim()
visibleBooks = filterBooks(currentBooks, query)
@@ -161,8 +214,8 @@ class BookshelfFragment : Fragment() {
}
private fun renderLibraryState(allBooks: List, displayedBooks: List, query: String) {
- val isSearching = query.isNotBlank()
- continueReadingBook = if (isSearching) {
+ val isFiltered = query.isNotBlank() || libraryFilter != LibraryFilter.ALL
+ continueReadingBook = if (isFiltered) {
null
} else {
allBooks
@@ -172,7 +225,7 @@ class BookshelfFragment : Fragment() {
}
val isLibraryEmpty = allBooks.isEmpty()
- val isSearchEmpty = !isLibraryEmpty && isSearching && displayedBooks.isEmpty()
+ val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty()
val hasVisibleBooks = displayedBooks.isNotEmpty()
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
binding.emptySearchCard.visibility = if (isSearchEmpty) View.VISIBLE else View.GONE
@@ -182,7 +235,11 @@ class BookshelfFragment : Fragment() {
if (continueReadingBook != null) View.VISIBLE else View.GONE
if (isSearchEmpty) {
- binding.emptySearchDetailText.text = getString(R.string.status_empty_library_search_subtitle, query)
+ binding.emptySearchDetailText.text = if (query.isNotBlank()) {
+ getString(R.string.status_empty_library_search_subtitle, query)
+ } else {
+ getString(R.string.library_filter_empty)
+ }
}
continueReadingBook?.let { book ->
@@ -247,33 +304,72 @@ class BookshelfFragment : Fragment() {
private fun filterBooks(books: List, query: String): List {
val search = query.lowercase().trim()
- if (search.isBlank()) {
- return books
- }
-
return books.filter { book ->
- book.title.lowercase().contains(search) || book.author.lowercase().contains(search)
+ val matchesSearch = search.isBlank() ||
+ book.title.lowercase().contains(search) ||
+ book.author.lowercase().contains(search)
+ val matchesFilter = when (libraryFilter) {
+ LibraryFilter.ALL,
+ LibraryFilter.DOWNLOADED -> true
+ LibraryFilter.READING -> book.isInProgress
+ LibraryFilter.FINISHED -> book.isCompleted
+ }
+ matchesSearch && matchesFilter
}
}
+ private enum class LibraryFilter {
+ ALL,
+ READING,
+ DOWNLOADED,
+ FINISHED
+ }
+
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
private fun openBook(book: Book) {
startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
}
- private fun openQBooksBookPreview(_book: QBooksBook) {
- openQBooks()
+ private fun openQBooksBookPreview(book: QBooksBook) {
+ startActivity(BookDetailActivity.createIntent(requireContext(), book))
}
private fun openQBooks() {
- (activity as? MainActivity)?.selectTab(R.id.nav_qbooks)
+ (activity as? MainActivity)?.selectTab(R.id.nav_search)
}
private fun launchImport() {
importBookLauncher.launch(arrayOf("application/epub+zip", "application/x-fictionbook+xml", "application/xml", "text/xml"))
}
+ private fun showBookActions(anchor: View, book: Book) {
+ PopupMenu(requireContext(), anchor).apply {
+ inflate(R.menu.book_actions_menu)
+ setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.action_share_book -> {
+ shareBook(book)
+ true
+ }
+ R.id.action_delete_book -> {
+ confirmDeleteBook(book)
+ true
+ }
+ else -> false
+ }
+ }
+ show()
+ }
+ }
+
+ private fun shareBook(book: Book) {
+ runCatching { BookSharing.shareDownloadedBook(requireContext(), book) }
+ .onFailure { exception ->
+ showMessage(getString(R.string.dialog_share_book_failed, exception.readableMessage()))
+ }
+ }
+
private fun confirmDeleteBook(book: Book) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.dialog_delete_book_title)
@@ -311,7 +407,9 @@ class BookshelfFragment : Fragment() {
private fun Throwable.readableMessage(): String = message ?: javaClass.simpleName
companion object {
+ const val REQUEST_REFRESH_LIBRARY = "refresh_my_books_library"
private const val QBOOKS_PREVIEW_SIZE = 8
+ private const val METADATA_PREFETCH_LIMIT = 8
fun newInstance() = BookshelfFragment()
}
diff --git a/app/src/main/java/com/aletheia/app/ui/books/QBooksShelfAdapter.kt b/app/src/main/java/com/aletheia/app/ui/books/QBooksShelfAdapter.kt
index 20953a5..c07978d 100644
--- a/app/src/main/java/com/aletheia/app/ui/books/QBooksShelfAdapter.kt
+++ b/app/src/main/java/com/aletheia/app/ui/books/QBooksShelfAdapter.kt
@@ -25,6 +25,25 @@ class QBooksShelfAdapter(
submitItems(listOf(QBooksShelfItem.StatusItem(title, detail)))
}
+ fun updateCover(bookId: String, coverImage: ByteArray) {
+ val index = items.indexOfFirst { item ->
+ item is QBooksShelfItem.BookItem && item.book.id == bookId
+ }
+ if (index < 0) return
+ val item = items[index] as QBooksShelfItem.BookItem
+ items[index] = item.copy(book = item.book.copy(coverImage = coverImage))
+ notifyItemChanged(index)
+ }
+
+ fun updateBook(book: QBooksBook) {
+ val index = items.indexOfFirst { item ->
+ item is QBooksShelfItem.BookItem && item.book.id == book.id
+ }
+ if (index < 0) return
+ items[index] = QBooksShelfItem.BookItem(book)
+ notifyItemChanged(index)
+ }
+
override fun getItemViewType(position: Int): Int = when (items[position]) {
is QBooksShelfItem.BookItem -> VIEW_TYPE_BOOK
is QBooksShelfItem.StatusItem -> VIEW_TYPE_STATUS
@@ -74,7 +93,7 @@ class QBooksShelfAdapter(
val format = book.format.uppercase()
binding.bookTitle.text = book.title
binding.bookAuthor.text = book.author
- binding.bookFormat.text = format
+ binding.bookFormat.text = context.getString(R.string.home_text_book)
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
binding.root.contentDescription = context.getString(
R.string.a11y_qbooks_book_card,
diff --git a/app/src/main/java/com/aletheia/app/ui/home/HomeFragment.kt b/app/src/main/java/com/aletheia/app/ui/home/HomeFragment.kt
new file mode 100644
index 0000000..fe2816d
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/home/HomeFragment.kt
@@ -0,0 +1,238 @@
+package com.aletheia.app.ui.home
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.aletheia.app.AletheiaApplication
+import com.aletheia.app.R
+import com.aletheia.app.data.Result
+import com.aletheia.app.databinding.FragmentHomeBinding
+import com.aletheia.app.model.Book
+import com.aletheia.app.model.QBooksBook
+import com.aletheia.app.ui.books.BooksAdapter
+import com.aletheia.app.ui.books.QBooksShelfAdapter
+import com.aletheia.app.ui.main.MainActivity
+import com.aletheia.app.ui.qbooks.BookDetailActivity
+import com.aletheia.app.ui.reader.ReaderActivity
+import com.aletheia.app.util.setBookCover
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.math.roundToInt
+
+class HomeFragment : Fragment() {
+
+ private var _binding: FragmentHomeBinding? = null
+ private val binding get() = _binding!!
+ private val app by lazy { requireActivity().application as AletheiaApplication }
+
+ private lateinit var catalogAdapter: QBooksShelfAdapter
+ private lateinit var downloadedAdapter: BooksAdapter
+ private var bannerBooks: List = emptyList()
+ private var catalogJob: Job? = null
+ private val coverJobs = mutableListOf()
+ private val localMetadataJobs = mutableListOf()
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
+ _binding = FragmentHomeBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, state: Bundle?) {
+ super.onViewCreated(view, state)
+
+ catalogAdapter = QBooksShelfAdapter(::openBookDetails, ::openSearch)
+ binding.catalogRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
+ binding.catalogRecycler.adapter = catalogAdapter
+
+ downloadedAdapter = BooksAdapter(
+ onBookClicked = ::openBook,
+ onBookLongPressed = { openMyBooks() },
+ onAddBookClicked = ::openMyBooks,
+ includeAddItem = false
+ )
+ binding.downloadedRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
+ binding.downloadedRecycler.adapter = downloadedAdapter
+
+ binding.homeSearch.setOnClickListener { openSearch() }
+ binding.catalogAll.setOnClickListener { openSearch() }
+ binding.downloadedAll.setOnClickListener { openMyBooks() }
+ listOf(binding.chipGenres, binding.chipRecommendations, binding.chipBooks, binding.chipAuthors).forEach { chip ->
+ chip.setOnClickListener { openSearch() }
+ }
+ binding.homeBannerFirst.setOnClickListener { openBanner(0) }
+ binding.homeBannerSecond.setOnClickListener { openBanner(1) }
+ binding.homeBannerThird.setOnClickListener { openBanner(2) }
+ binding.homeBannerScroll.post {
+ binding.homeBannerScroll.scrollTo(dp(BANNER_INITIAL_OFFSET_DP), 0)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ loadLocalBooks()
+ loadCatalog()
+ }
+
+ override fun onDestroyView() {
+ catalogJob?.cancel()
+ coverJobs.forEach { it.cancel() }
+ coverJobs.clear()
+ localMetadataJobs.forEach { it.cancel() }
+ localMetadataJobs.clear()
+ _binding = null
+ super.onDestroyView()
+ }
+
+ private fun loadLocalBooks() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val books = withContext(Dispatchers.IO) { app.bookRepository.getAllBooks() }
+ if (_binding == null) return@launch
+ downloadedAdapter.submitList(books.take(HOME_BOOK_LIMIT))
+ binding.downloadedEmpty.visibility = if (books.isEmpty()) View.VISIBLE else View.GONE
+ binding.downloadedRecycler.visibility = if (books.isEmpty()) View.GONE else View.VISIBLE
+ enrichLocalBooks(books.take(HOME_BOOK_LIMIT))
+ }
+ }
+
+ private fun enrichLocalBooks(books: List) {
+ localMetadataJobs.forEach { it.cancel() }
+ localMetadataJobs.clear()
+ books.forEach { book ->
+ localMetadataJobs += viewLifecycleOwner.lifecycleScope.launch {
+ val enriched = app.qBooksService.enrichLocalBook(book)
+ if (enriched == book || _binding == null) return@launch
+ withContext(Dispatchers.IO) { app.bookRepository.updateBook(enriched) }
+ downloadedAdapter.updateBook(enriched)
+ }
+ }
+ }
+
+ private fun loadCatalog() {
+ catalogJob?.cancel()
+ coverJobs.forEach { it.cancel() }
+ coverJobs.clear()
+ catalogJob = viewLifecycleOwner.lifecycleScope.launch {
+ val cached = app.qBooksService.getCachedCatalogPage(pageSize = CATALOG_LIMIT)
+ if (_binding == null) return@launch
+ val cachedBooks = cached?.page?.books.orEmpty()
+ if (cachedBooks.isNotEmpty()) {
+ catalogAdapter.submitBooks(cachedBooks)
+ updateBanners(cachedBooks)
+ loadCovers(cachedBooks)
+ } else {
+ catalogAdapter.submitStatus(
+ getString(R.string.qbooks_home_loading_title),
+ getString(R.string.qbooks_home_loading_subtitle)
+ )
+ }
+ if (cached?.isFresh(HOME_CACHE_MAX_AGE_MS) == true) return@launch
+
+ val result = withContext(Dispatchers.IO) { app.qBooksService.getBooks(pageSize = CATALOG_LIMIT) }
+ if (_binding == null) return@launch
+ when (result) {
+ is Result.Success -> if (result.value.isEmpty()) {
+ catalogAdapter.submitStatus(
+ getString(R.string.qbooks_home_empty_title),
+ getString(R.string.qbooks_home_empty_subtitle)
+ )
+ } else {
+ catalogAdapter.submitBooks(result.value)
+ updateBanners(result.value)
+ loadCovers(result.value)
+ }
+ is Result.Failure -> if (cachedBooks.isEmpty()) {
+ catalogAdapter.submitStatus(
+ getString(R.string.qbooks_home_error_title),
+ getString(R.string.qbooks_home_error_subtitle)
+ )
+ }
+ }
+ }
+ }
+
+ private fun loadCovers(books: List) {
+ books.forEach { book ->
+ coverJobs += viewLifecycleOwner.lifecycleScope.launch {
+ var enriched = app.qBooksService.enrichBookMetadata(book)
+ if (_binding != null && enriched != book) {
+ catalogAdapter.updateBook(enriched)
+ updateBannerBook(enriched)
+ }
+ val cover = if (enriched.coverImage == null && !enriched.coverUrl.isNullOrBlank()) {
+ app.qBooksService.fetchCoverImage(enriched)
+ } else {
+ enriched.coverImage
+ }
+ if (cover != null && _binding != null) {
+ enriched = enriched.copy(coverImage = cover)
+ catalogAdapter.updateBook(enriched)
+ updateBannerBook(enriched)
+ }
+ }
+ }
+ }
+
+ private fun updateBanners(books: List) {
+ if (_binding == null) return
+ val candidates = books.take(BANNER_CANDIDATE_COUNT).sortedBy { book -> book.title.length }.take(BANNER_COUNT)
+ bannerBooks = when (candidates.size) {
+ 3 -> listOf(candidates[1], candidates[0], candidates[2])
+ 2 -> listOf(candidates[1], candidates[0])
+ else -> candidates
+ }
+ val covers = listOf(binding.homeBannerCover1, binding.homeBannerCover2, binding.homeBannerCover3)
+ val titles = listOf(binding.homeBannerTitle1, binding.homeBannerTitle2, binding.homeBannerTitle3)
+ val subtitles = listOf(binding.homeBannerSubtitle1, binding.homeBannerSubtitle2, binding.homeBannerSubtitle3)
+ bannerBooks.forEachIndexed { index, book ->
+ covers[index].setBookCover(book.coverImage, R.drawable.default_cover)
+ titles[index].text = book.title
+ subtitles[index].text = book.author
+ }
+ }
+
+ private fun updateBannerBook(book: QBooksBook) {
+ if (bannerBooks.none { it.id == book.id }) return
+ updateBanners(bannerBooks.map { current -> if (current.id == book.id) book else current })
+ }
+
+ private fun openBanner(index: Int) {
+ bannerBooks.getOrNull(index)?.let(::openBookDetails) ?: openSearch()
+ }
+
+ private fun openBook(book: Book) {
+ startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
+ }
+
+ private fun openBookDetails(book: QBooksBook) {
+ startActivity(BookDetailActivity.createIntent(requireContext(), book))
+ }
+
+ private fun openSearch() {
+ (activity as? MainActivity)?.selectTab(R.id.nav_search)
+ }
+
+ private fun openMyBooks() {
+ (activity as? MainActivity)?.selectTab(R.id.nav_my_books)
+ }
+
+ private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
+ private fun dp(value: Float): Int = (value * resources.displayMetrics.density).roundToInt()
+
+ companion object {
+ private const val CATALOG_LIMIT = 12
+ private const val HOME_BOOK_LIMIT = 8
+ private const val BANNER_COUNT = 3
+ private const val BANNER_CANDIDATE_COUNT = 8
+ private const val BANNER_INITIAL_OFFSET_DP = 332.5f
+ private const val HOME_CACHE_MAX_AGE_MS = 15 * 60 * 1000L
+ fun newInstance() = HomeFragment()
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/main/MainActivity.kt b/app/src/main/java/com/aletheia/app/ui/main/MainActivity.kt
index 6b44002..7d39040 100644
--- a/app/src/main/java/com/aletheia/app/ui/main/MainActivity.kt
+++ b/app/src/main/java/com/aletheia/app/ui/main/MainActivity.kt
@@ -1,22 +1,42 @@
package com.aletheia.app.ui.main
+import android.content.Intent
+import android.graphics.Color
+import android.os.Build
import android.os.Bundle
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
-import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.commit
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.aletheia.app.AletheiaApplication
import com.aletheia.app.R
import com.aletheia.app.databinding.ActivityMainBinding
+import com.aletheia.app.model.Book
import com.aletheia.app.ui.books.BookshelfFragment
-import com.aletheia.app.ui.settings.SettingsFragment
+import com.aletheia.app.ui.home.HomeFragment
import com.aletheia.app.ui.qbooks.QBooksLibraryFragment
+import com.aletheia.app.ui.reader.ReaderActivity
+import com.aletheia.app.ui.reader.ReaderHubFragment
+import com.aletheia.app.ui.settings.SettingsFragment
+import com.aletheia.app.util.setBookCover
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.math.roundToInt
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
+ private val app by lazy { application as AletheiaApplication }
+ private var miniReaderBook: Book? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -25,61 +45,126 @@ class MainActivity : AppCompatActivity() {
setupSystemBars()
binding.bottomNavigation.setOnItemSelectedListener { item ->
- when (item.itemId) {
- R.id.nav_library -> {
- showBookshelf()
- true
- }
- R.id.nav_qbooks -> {
- showQBooks()
- true
- }
- R.id.nav_settings -> {
- showSettings()
- true
- }
- else -> false
+ openTab(item.itemId)
+ }
+ binding.bottomNavigation.setOnItemReselectedListener { item ->
+ if (item.itemId == R.id.nav_my_books) {
+ requestMyBooksRefresh()
}
}
+ binding.miniReaderPanel.setOnClickListener { openMiniReader() }
+ binding.miniReaderOpen.setOnClickListener { openMiniReader() }
if (savedInstanceState == null) {
- binding.bottomNavigation.selectedItemId = resolveInitialTab(intent?.getStringExtra(EXTRA_OPEN_TAB))
+ selectOrOpenTab(resolveInitialTab(intent?.getStringExtra(EXTRA_OPEN_TAB)))
+ } else if (supportFragmentManager.fragments.none { !it.isHidden }) {
+ selectOrOpenTab(R.id.nav_home)
}
}
+ override fun onResume() {
+ super.onResume()
+ refreshMiniReader()
+ }
+
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
intent.getStringExtra(EXTRA_OPEN_TAB)?.let { tab ->
- binding.bottomNavigation.selectedItemId = resolveInitialTab(tab)
+ selectOrOpenTab(resolveInitialTab(tab))
}
}
- private fun showBookshelf() {
- supportFragmentManager.beginTransaction()
- .replace(R.id.main_fragment_container, BookshelfFragment.newInstance())
- .commit()
+ private fun selectOrOpenTab(itemId: Int) {
+ if (binding.bottomNavigation.selectedItemId == itemId) {
+ openTab(itemId)
+ } else {
+ binding.bottomNavigation.selectedItemId = itemId
+ }
}
- private fun showQBooks() {
- supportFragmentManager.beginTransaction()
- .replace(R.id.main_fragment_container, QBooksLibraryFragment.newInstance())
- .commit()
+ private fun openTab(itemId: Int): Boolean = when (itemId) {
+ R.id.nav_home -> showTab(TAG_HOME) { HomeFragment.newInstance() }
+ R.id.nav_search -> showTab(TAG_SEARCH) { QBooksLibraryFragment.newInstance() }
+ R.id.nav_reader -> showTab(TAG_READER) { ReaderHubFragment.newInstance() }
+ R.id.nav_my_books -> showTab(TAG_MY_BOOKS) { BookshelfFragment.newInstance() }
+ R.id.nav_profile -> showTab(TAG_PROFILE) { SettingsFragment.newInstance() }
+ else -> false
}
- private fun showSettings() {
- supportFragmentManager.beginTransaction()
- .replace(R.id.main_fragment_container, SettingsFragment.newInstance())
- .commit()
+ private fun showTab(tag: String, factory: () -> Fragment): Boolean {
+ val current = supportFragmentManager.fragments.firstOrNull { !it.isHidden }
+ val target = supportFragmentManager.findFragmentByTag(tag) ?: factory()
+ if (current === target) {
+ if (tag == TAG_MY_BOOKS) requestMyBooksRefresh()
+ return true
+ }
+
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ current?.let { fragment ->
+ hide(fragment)
+ setMaxLifecycle(fragment, Lifecycle.State.STARTED)
+ }
+ if (target.isAdded) {
+ show(target)
+ } else {
+ add(R.id.main_fragment_container, target, tag)
+ }
+ setMaxLifecycle(target, Lifecycle.State.RESUMED)
+ }
+ if (tag == TAG_MY_BOOKS) requestMyBooksRefresh()
+ return true
+ }
+
+ private fun requestMyBooksRefresh() {
+ supportFragmentManager.setFragmentResult(
+ BookshelfFragment.REQUEST_REFRESH_LIBRARY,
+ Bundle.EMPTY
+ )
}
fun selectTab(itemId: Int) {
binding.bottomNavigation.selectedItemId = itemId
}
+ private fun refreshMiniReader() {
+ lifecycleScope.launch {
+ val book = withContext(Dispatchers.IO) {
+ app.bookRepository.getAllBooks().maxByOrNull { item -> item.lastRead }
+ }
+ miniReaderBook = book
+ val visibility = if (book == null) View.GONE else View.VISIBLE
+ binding.miniReaderPanel.visibility = visibility
+ binding.miniReaderProgress.visibility = visibility
+ if (book == null) return@launch
+
+ binding.miniReaderCover.setBookCover(book.coverImage, R.drawable.default_cover)
+ binding.miniReaderTitle.text = book.title
+ binding.miniReaderAuthor.text = book.author
+ binding.miniReaderProgress.progress =
+ (book.readingProgress * 100).roundToInt().coerceIn(0, 100)
+ binding.miniReaderPanel.contentDescription = getString(R.string.home_open_book, book.title)
+ }
+ }
+
+ private fun openMiniReader() {
+ val book = miniReaderBook ?: return
+ startActivity(
+ Intent(this, ReaderActivity::class.java)
+ .putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id)
+ )
+ }
+
private fun setupSystemBars() {
- WindowCompat.getInsetsController(window, binding.root).isAppearanceLightStatusBars = false
- WindowCompat.getInsetsController(window, binding.root).isAppearanceLightNavigationBars = false
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+ WindowCompat.getInsetsController(window, binding.root).isAppearanceLightStatusBars = true
+ WindowCompat.getInsetsController(window, binding.root).isAppearanceLightNavigationBars = true
val baseContainerTop = binding.mainFragmentContainer.paddingTop
val baseNavigationLeft = binding.bottomNavigation.paddingLeft
@@ -96,10 +181,10 @@ class MainActivity : AppCompatActivity() {
left = baseNavigationLeft,
top = baseNavigationTop,
right = baseNavigationRight,
- bottom = baseNavigationBottom
+ bottom = baseNavigationBottom + bars.bottom
)
binding.bottomNavigation.updateLayoutParams {
- bottomMargin = baseNavigationBottomMargin + bars.bottom
+ bottomMargin = baseNavigationBottomMargin
}
insets
}
@@ -107,12 +192,19 @@ class MainActivity : AppCompatActivity() {
}
private fun resolveInitialTab(tab: String?): Int = when (tab?.trim()?.lowercase()) {
- "calibre", "qbooks" -> R.id.nav_qbooks
- "settings" -> R.id.nav_settings
- else -> R.id.nav_library
+ "search", "catalog", "calibre", "qbooks" -> R.id.nav_search
+ "reader" -> R.id.nav_reader
+ "library", "books", "my-books" -> R.id.nav_my_books
+ "settings", "profile" -> R.id.nav_profile
+ else -> R.id.nav_home
}
companion object {
const val EXTRA_OPEN_TAB = "open_tab"
+ private const val TAG_HOME = "home"
+ private const val TAG_SEARCH = "search"
+ private const val TAG_READER = "reader"
+ private const val TAG_MY_BOOKS = "my-books"
+ private const val TAG_PROFILE = "profile"
}
}
diff --git a/app/src/main/java/com/aletheia/app/ui/qbooks/BookDetailActivity.kt b/app/src/main/java/com/aletheia/app/ui/qbooks/BookDetailActivity.kt
new file mode 100644
index 0000000..2f45505
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/qbooks/BookDetailActivity.kt
@@ -0,0 +1,255 @@
+package com.aletheia.app.ui.qbooks
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.widget.FrameLayout
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import androidx.lifecycle.lifecycleScope
+import com.aletheia.app.AletheiaApplication
+import com.aletheia.app.R
+import com.aletheia.app.data.Result
+import com.aletheia.app.databinding.ActivityBookDetailBinding
+import com.aletheia.app.model.Book
+import com.aletheia.app.model.QBooksBook
+import com.aletheia.app.ui.reader.ReaderActivity
+import com.aletheia.app.util.BookSharing
+import com.aletheia.app.util.setBookCover
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class BookDetailActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityBookDetailBinding
+ private val app by lazy { application as AletheiaApplication }
+ private lateinit var book: QBooksBook
+ private var localBook: Book? = null
+ private var isDownloading = false
+
+ override fun onCreate(state: Bundle?) {
+ super.onCreate(state)
+ binding = ActivityBookDetailBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ WindowCompat.getInsetsController(window, binding.root).isAppearanceLightStatusBars = true
+ WindowCompat.getInsetsController(window, binding.root).isAppearanceLightNavigationBars = true
+ setupSystemBarInsets()
+
+ book = readBook() ?: run {
+ finish()
+ return
+ }
+ binding.backButton.setOnClickListener { finish() }
+ binding.shareButton.setOnClickListener { shareBook() }
+ binding.favoriteButton.setOnClickListener { toggleFavorite() }
+ renderBook()
+ loadState()
+ loadMetadataAndCover()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (::book.isInitialized) loadState()
+ }
+
+ private fun renderBook() {
+ binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
+ binding.bookBackdrop.setBookCover(book.coverImage, R.drawable.default_cover)
+ binding.bookTitle.text = book.title
+ binding.bookAuthor.text = book.author
+ binding.bookFormat.text = book.format.uppercase()
+ binding.bookDescription.text = book.description?.takeIf(String::isNotBlank)
+ ?: getString(R.string.book_detail_no_description)
+
+ val details = buildList {
+ book.language?.takeIf(String::isNotBlank)?.let { add(getString(R.string.book_detail_language, it)) }
+ book.published?.takeIf(String::isNotBlank)?.let { add(getString(R.string.book_detail_published, it.take(4))) }
+ book.publisher?.takeIf(String::isNotBlank)?.let { add(getString(R.string.book_detail_publisher, it)) }
+ }
+ binding.bookInfo.text = details.joinToString(" • ")
+ binding.bookInfo.visibility = if (details.isEmpty()) View.GONE else View.VISIBLE
+ renderFavorite()
+ }
+
+ private fun loadMetadataAndCover() {
+ lifecycleScope.launch {
+ book = app.qBooksService.enrichBookMetadata(book, includeDetails = true)
+ renderBook()
+ if (book.coverImage != null || book.coverUrl.isNullOrBlank()) return@launch
+ val cover = app.qBooksService.fetchCoverImage(book)
+ if (cover != null) {
+ book = book.copy(coverImage = cover)
+ binding.bookCover.setBookCover(cover, R.drawable.default_cover)
+ binding.bookBackdrop.setBookCover(cover, R.drawable.default_cover)
+ }
+ }
+ }
+
+ private fun setupSystemBarInsets() {
+ val backTopMargin = (binding.backButton.layoutParams as FrameLayout.LayoutParams).topMargin
+ val shareTopMargin = (binding.shareButton.layoutParams as FrameLayout.LayoutParams).topMargin
+ val favoriteTopMargin = (binding.favoriteButton.layoutParams as FrameLayout.LayoutParams).topMargin
+ val actionBottomPadding = binding.bottomActionBar.paddingBottom
+ val scrollBottomPadding = binding.bookScroll.paddingBottom
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ binding.backButton.updateLayoutParams {
+ topMargin = backTopMargin + bars.top
+ }
+ binding.shareButton.updateLayoutParams {
+ topMargin = shareTopMargin + bars.top
+ }
+ binding.favoriteButton.updateLayoutParams {
+ topMargin = favoriteTopMargin + bars.top
+ }
+ binding.bottomActionBar.updatePadding(bottom = actionBottomPadding + bars.bottom)
+ binding.bookScroll.updatePadding(bottom = scrollBottomPadding + bars.bottom)
+ insets
+ }
+ ViewCompat.requestApplyInsets(binding.root)
+ }
+
+ private fun loadState() {
+ lifecycleScope.launch {
+ localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id) }
+ updatePrimaryAction()
+ }
+ }
+
+ private fun updatePrimaryAction() {
+ if (isDownloading) return
+ val downloaded = localBook
+ binding.downloadProgress.visibility = View.GONE
+ binding.primaryAction.isEnabled = true
+ binding.primaryAction.text = getString(
+ if (downloaded == null) R.string.book_detail_download else R.string.book_detail_read
+ )
+ binding.downloadState.text = if (downloaded == null) "" else getString(R.string.book_detail_downloaded)
+ binding.downloadState.visibility = if (downloaded == null) View.GONE else View.VISIBLE
+ binding.primaryAction.setOnClickListener {
+ if (downloaded == null) downloadBook() else openBook(downloaded)
+ }
+ }
+
+ private fun downloadBook() {
+ if (isDownloading) return
+ isDownloading = true
+ binding.primaryAction.isEnabled = false
+ binding.downloadProgress.visibility = View.VISIBLE
+ binding.downloadProgress.progress = 0
+ binding.primaryAction.text = getString(R.string.book_detail_downloading, 0)
+
+ lifecycleScope.launch {
+ val result = withContext(Dispatchers.IO) {
+ runCatching {
+ app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it }
+ val progress: (Int) -> Unit = { value ->
+ runOnUiThread {
+ binding.downloadProgress.progress = value
+ binding.primaryAction.text = getString(R.string.book_detail_downloading, value)
+ }
+ }
+ val temp = when (val download = app.qBooksService.downloadBook(
+ book,
+ app.cacheDir,
+ progress
+ )) {
+ is Result.Success -> download.value
+ is Result.Failure -> error(download.message)
+ }
+ try {
+ app.bookRepository.importDownloadedBook(
+ file = temp,
+ originalFileName = safeFileName(book),
+ remoteId = book.id,
+ coverImage = book.coverImage
+ )
+ } finally {
+ temp.takeIf(File::exists)?.delete()
+ }
+ }
+ }
+ isDownloading = false
+ result.onSuccess {
+ localBook = it
+ updatePrimaryAction()
+ }.onFailure {
+ updatePrimaryAction()
+ MaterialAlertDialogBuilder(this@BookDetailActivity)
+ .setTitle(R.string.dialog_error_title)
+ .setMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
+ .setPositiveButton(R.string.action_ok, null)
+ .show()
+ }
+ }
+ }
+
+ private fun openBook(book: Book) {
+ startActivity(Intent(this, ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
+ }
+
+ private fun shareBook() {
+ lifecycleScope.launch {
+ val downloadedBook = withContext(Dispatchers.IO) {
+ app.bookRepository.getBookByRemoteId(book.id)
+ }
+ runCatching {
+ if (downloadedBook == null) {
+ BookSharing.shareBookLink(this@BookDetailActivity, book)
+ } else {
+ BookSharing.shareDownloadedBook(this@BookDetailActivity, downloadedBook)
+ }
+ }.onFailure { exception ->
+ MaterialAlertDialogBuilder(this@BookDetailActivity)
+ .setTitle(R.string.dialog_error_title)
+ .setMessage(getString(R.string.dialog_share_book_failed, exception.message ?: exception.javaClass.simpleName))
+ .setPositiveButton(R.string.action_ok, null)
+ .show()
+ }
+ }
+ }
+
+ private fun toggleFavorite() {
+ val prefs = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE)
+ val favorites = prefs.getStringSet(FAVORITES_KEY, emptySet()).orEmpty().toMutableSet()
+ if (!favorites.add(book.id)) favorites.remove(book.id)
+ prefs.edit().putStringSet(FAVORITES_KEY, favorites).apply()
+ renderFavorite()
+ }
+
+ private fun renderFavorite() {
+ val selected = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE)
+ .getStringSet(FAVORITES_KEY, emptySet())
+ .orEmpty()
+ .contains(book.id)
+ binding.favoriteButton.text = if (selected) "♥" else "♡"
+ binding.favoriteButton.contentDescription = getString(
+ if (selected) R.string.book_detail_favorite_remove else R.string.book_detail_favorite_add
+ )
+ }
+
+ @Suppress("DEPRECATION")
+ private fun readBook(): QBooksBook? = intent.getSerializableExtra(EXTRA_BOOK) as? QBooksBook
+
+ private fun safeFileName(book: QBooksBook): String {
+ val title = book.title.replace(Regex("[\\\\/:*?\"<>|]"), "_")
+ return "$title.${book.format.lowercase()}"
+ }
+
+ companion object {
+ private const val EXTRA_BOOK = "catalog_book"
+ private const val FAVORITES_PREFS = "catalog_favorites"
+ private const val FAVORITES_KEY = "book_ids"
+
+ fun createIntent(context: Context, book: QBooksBook): Intent =
+ Intent(context, BookDetailActivity::class.java)
+ .putExtra(EXTRA_BOOK, book.copy(coverImage = null))
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksCatalogAdapter.kt b/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksCatalogAdapter.kt
index 803b4b8..b3d86d7 100644
--- a/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksCatalogAdapter.kt
+++ b/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksCatalogAdapter.kt
@@ -10,6 +10,7 @@ import com.aletheia.app.model.QBooksBook
import com.aletheia.app.util.setBookCover
class QBooksCatalogAdapter(
+ private val onBookClicked: (QBooksBook) -> Unit,
private val onDownloadClicked: (QBooksBook) -> Unit
) : RecyclerView.Adapter() {
@@ -44,8 +45,31 @@ class QBooksCatalogAdapter(
notifyItemRangeInserted(startIndex, newItems.size)
}
+ fun appendOrUpdate(books: List) {
+ val merged = items.toMutableList()
+ books.forEach { book ->
+ val index = merged.indexOfFirst { it.id == book.id }
+ if (index >= 0) merged[index] = book else merged += book
+ }
+ submitList(merged)
+ }
+
fun currentItems(): List = items.toList()
+ fun updateCover(bookId: String, coverImage: ByteArray) {
+ val index = items.indexOfFirst { it.id == bookId }
+ if (index < 0) return
+ items[index] = items[index].copy(coverImage = coverImage)
+ notifyItemChanged(index)
+ }
+
+ fun updateBook(book: QBooksBook) {
+ val index = items.indexOfFirst { it.id == book.id }
+ if (index < 0) return
+ items[index] = book
+ notifyItemChanged(index)
+ }
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QBooksBookViewHolder {
val binding = ItemQbooksBookBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return QBooksBookViewHolder(binding)
@@ -78,7 +102,7 @@ class QBooksCatalogAdapter(
R.string.a11y_download_qbooks_book,
book.title
)
- binding.root.setOnClickListener { onDownloadClicked(book) }
+ binding.root.setOnClickListener { onBookClicked(book) }
binding.downloadButton.setOnClickListener { onDownloadClicked(book) }
}
}
diff --git a/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksLibraryFragment.kt b/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksLibraryFragment.kt
index fdc439e..9891f43 100644
--- a/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksLibraryFragment.kt
+++ b/app/src/main/java/com/aletheia/app/ui/qbooks/QBooksLibraryFragment.kt
@@ -29,41 +29,37 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class QBooksLibraryFragment : Fragment() {
-
private var _binding: FragmentQbooksLibraryBinding? = null
private val binding get() = _binding!!
-
private val app by lazy { requireActivity().application as AletheiaApplication }
private lateinit var booksAdapter: QBooksCatalogAdapter
private var currentPage = 0
+ private var nextPageUrl: String? = null
private var isConfigured = false
private var isLoading = false
private var hasMoreBooks = true
private var searchJob: Job? = null
+ private var loadJob: Job? = null
+ private val coverJobs = mutableListOf()
+ private val activeDownloads = mutableSetOf()
+ private var requestGeneration = 0
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
_binding = FragmentQbooksLibraryBinding.inflate(inflater, container, false)
return binding.root
}
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- booksAdapter = QBooksCatalogAdapter(::downloadBook)
+ override fun onViewCreated(view: View, state: Bundle?) {
+ super.onViewCreated(view, state)
+ booksAdapter = QBooksCatalogAdapter(::openBookDetails, ::downloadBook)
binding.qbooksRecycler.layoutManager = LinearLayoutManager(requireContext())
binding.qbooksRecycler.adapter = booksAdapter
binding.qbooksRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
- if (dy <= 0 || !isConfigured || !hasMoreBooks || isLoading) {
- return
- }
-
- val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
- val lastVisible = layoutManager.findLastVisibleItemPosition()
- if (lastVisible >= booksAdapter.itemCount - 5) {
- loadBooks(reset = false)
- }
+ if (dy <= 0 || !isConfigured || !hasMoreBooks || isLoading) return
+ val manager = recyclerView.layoutManager as? LinearLayoutManager ?: return
+ if (manager.findLastVisibleItemPosition() >= booksAdapter.itemCount - 4) loadBooks(reset = false)
}
})
@@ -74,315 +70,278 @@ class QBooksLibraryFragment : Fragment() {
loadBooks(reset = true)
}
}
-
binding.searchInput.setOnEditorActionListener { _, _, _ ->
loadBooks(reset = true)
true
}
- binding.openSettingsButton.setOnClickListener {
- (activity as? MainActivity)?.selectTab(R.id.nav_settings)
- }
- binding.errorSettingsButton.setOnClickListener {
- (activity as? MainActivity)?.selectTab(R.id.nav_settings)
- }
- binding.retryButton.setOnClickListener {
- loadBooks(reset = true)
- }
- binding.refreshCatalogButton.setOnClickListener {
- loadBooks(reset = true)
- }
- binding.catalogSettingsButton.setOnClickListener {
- (activity as? MainActivity)?.selectTab(R.id.nav_settings)
- }
+ binding.openSettingsButton.setOnClickListener { openProfile() }
+ binding.errorSettingsButton.setOnClickListener { openProfile() }
+ binding.catalogSettingsButton.setOnClickListener { openProfile() }
+ binding.retryButton.setOnClickListener { loadBooks(reset = true, forceRefresh = true) }
+ binding.refreshCatalogButton.setOnClickListener { loadBooks(reset = true, forceRefresh = true) }
+ binding.swipeRefresh.setOnRefreshListener { loadBooks(reset = true, forceRefresh = true) }
- binding.swipeRefresh.setOnRefreshListener {
- loadBooks(reset = true)
+ val genreChips = mapOf(
+ binding.genreFantasy to R.string.genre_fantasy,
+ binding.genreDetective to R.string.genre_detective,
+ binding.genreRomance to R.string.genre_romance,
+ binding.genreFantasyMagic to R.string.genre_fantasy_magic,
+ binding.genreHistory to R.string.genre_history,
+ binding.genreScience to R.string.genre_science
+ )
+ genreChips.forEach { (chip, title) ->
+ chip.setOnClickListener { binding.searchInput.setText(getString(title)) }
}
}
override fun onResume() {
super.onResume()
- initializeQBooks()
+ initializeCatalog()
}
override fun onDestroyView() {
searchJob?.cancel()
+ loadJob?.cancel()
+ coverJobs.forEach { it.cancel() }
+ coverJobs.clear()
_binding = null
super.onDestroyView()
}
- private fun initializeQBooks() {
- val url = app.settingsRepository.getString(
- SettingsRepository.KEY_QBOOKS_URL,
- app.settingsRepository.getString(SettingsRepository.KEY_LEGACY_CATALOG_URL)
- ).trim()
- val username = app.settingsRepository.getString(
- SettingsRepository.KEY_QBOOKS_USERNAME,
- app.settingsRepository.getString(SettingsRepository.KEY_LEGACY_CATALOG_USERNAME)
- )
- val password = app.settingsRepository.getSecurePassword()
-
- if (url.isBlank()) {
- isConfigured = false
- setCatalogStatus(null)
- renderState(showConfigured = false, showError = false, errorMessage = null)
- return
+ private fun initializeCatalog() {
+ var url = app.settingsRepository.getCatalogUrl()
+ var validation = QBooksUrlPolicy.validate(url)
+ if (!validation.isAllowed) {
+ url = SettingsRepository.DEFAULT_CATALOG_URL
+ app.settingsRepository.setString(SettingsRepository.KEY_QBOOKS_URL, url)
+ validation = QBooksUrlPolicy.validate(url)
}
-
- val validation = QBooksUrlPolicy.validate(url)
if (!validation.isAllowed) {
isConfigured = false
- setCatalogStatus(null)
- renderState(
- showConfigured = false,
- showError = true,
- errorMessage = when (validation.reason) {
- QBooksUrlPolicy.Reason.ExternalCleartext -> getString(R.string.settings_url_cleartext_external)
- else -> getString(R.string.status_qbooks_invalid_url, url)
- }
- )
+ renderError(getString(R.string.status_qbooks_invalid_url))
return
}
-
- app.qBooksService.configure(url, username, password)
+ app.qBooksService.configure(url, null, null)
isConfigured = true
- renderState(showConfigured = true, showError = false, errorMessage = null)
- loadBooks(reset = true)
+ binding.notConfiguredCard.visibility = View.GONE
+ binding.errorCard.visibility = View.GONE
+ binding.searchCard.visibility = View.VISIBLE
+ binding.qbooksActionsRow.visibility = View.VISIBLE
+ binding.swipeRefresh.visibility = View.VISIBLE
+ if (booksAdapter.itemCount == 0) loadBooks(reset = true)
}
- private fun loadBooks(reset: Boolean) {
- if (!isConfigured || isLoading || (!reset && !hasMoreBooks)) {
+ private fun loadBooks(reset: Boolean, forceRefresh: Boolean = false) {
+ if (!isConfigured || (!reset && (isLoading || !hasMoreBooks))) {
binding.swipeRefresh.isRefreshing = false
return
}
-
- isLoading = true
- setCatalogStatus(
- getString(
- if (reset) {
- R.string.qbooks_catalog_loading
- } else {
- R.string.qbooks_catalog_loading_more
- }
- )
- )
if (reset) {
+ requestGeneration++
+ loadJob?.cancel()
+ coverJobs.forEach { it.cancel() }
+ coverJobs.clear()
+ isLoading = false
currentPage = 0
+ nextPageUrl = null
hasMoreBooks = true
binding.swipeRefresh.isRefreshing = true
}
- viewLifecycleOwner.lifecycleScope.launch {
- val pageToLoad = currentPage
- val query = currentSearchQuery()
- setCatalogOverview(
- title = getString(
- if (reset) {
- R.string.qbooks_catalog_overview_loading_title
- } else {
- R.string.qbooks_catalog_overview_loading_more_title
- }
- ),
- detail = getString(
- if (reset) {
- R.string.qbooks_catalog_loading
- } else {
- R.string.qbooks_catalog_loading_more
- }
- ),
- meta = queryMetaText(query)
- )
- val result = withContext(Dispatchers.IO) {
- app.qBooksService.getBooks(query, pageToLoad, PAGE_SIZE)
+ val generation = requestGeneration
+ val pageToLoad = currentPage
+ val pageUrl = if (reset) null else nextPageUrl
+ val query = currentSearchQuery()
+ isLoading = true
+ setOverview(
+ getString(
+ if (reset) R.string.qbooks_catalog_overview_loading_title
+ else R.string.qbooks_catalog_overview_loading_more_title
+ ),
+ getString(if (reset) R.string.qbooks_catalog_loading else R.string.qbooks_catalog_loading_more),
+ query.takeIf(String::isNotBlank)?.let { getString(R.string.qbooks_catalog_query_meta, it) }
+ )
+
+ loadJob = viewLifecycleOwner.lifecycleScope.launch {
+ val cached = app.qBooksService.getCachedCatalogPage(query, pageToLoad, PAGE_SIZE, pageUrl)
+ if (_binding == null || generation != requestGeneration) return@launch
+ val hasCachedPage = cached != null
+ if (cached != null) {
+ applyCatalogPage(cached.page, reset, pageToLoad, generation)
+ binding.swipeRefresh.isRefreshing = false
+ if (!forceRefresh && cached.isFresh(SEARCH_CACHE_MAX_AGE_MS)) {
+ isLoading = false
+ return@launch
+ }
}
- if (_binding == null) {
- return@launch
+ val result = withContext(Dispatchers.IO) {
+ app.qBooksService.getCatalogPage(query, pageToLoad, PAGE_SIZE, pageUrl)
}
+ if (_binding == null || generation != requestGeneration) return@launch
when (result) {
is Result.Success -> {
- val books = result.value
- if (reset) {
- booksAdapter.submitList(books)
- } else {
- booksAdapter.appendDistinct(books)
- }
- hasMoreBooks = books.size >= PAGE_SIZE
- if (!reset && books.isNotEmpty()) {
- currentPage++
- } else if (reset) {
- currentPage = 1
- }
- renderState(showConfigured = true, showError = false, errorMessage = null)
- binding.emptyResultsCard.visibility =
- if (booksAdapter.itemCount == 0) View.VISIBLE else View.GONE
- setLoadedCatalogStatus()
+ applyCatalogPage(result.value, reset, pageToLoad, generation)
}
-
- is Result.Failure -> {
- if (reset) {
- booksAdapter.submitList(emptyList())
- setCatalogStatus(null)
- renderState(showConfigured = false, showError = true, errorMessage = result.message)
- } else {
- setLoadedCatalogStatus()
- showMessage(result.message)
- }
+ is Result.Failure -> if (hasCachedPage) {
+ renderLoaded()
+ if (forceRefresh) showMessage(result.message)
+ } else if (reset) renderError(result.message) else {
+ renderLoaded()
+ showMessage(result.message)
}
}
-
binding.swipeRefresh.isRefreshing = false
isLoading = false
}
}
- private fun downloadBook(book: QBooksBook) {
- setDownloadStatus(getString(R.string.qbooks_download_status, book.title), true)
-
- viewLifecycleOwner.lifecycleScope.launch {
- val result = withContext(Dispatchers.IO) {
- runCatching {
- val progress: (Int) -> Unit = { value ->
- view?.post {
- setDownloadStatus(getString(R.string.qbooks_download_progress, value.toDouble()), true)
- }
- }
-
- val tempFile = when (val downloadResult = app.qBooksService.downloadBook(
- book = book,
- booksDirectory = app.bookParserService.getBooksDirectory(),
- progress = progress
- )) {
- is Result.Success -> downloadResult.value
- is Result.Failure -> error(downloadResult.message)
- }
-
- try {
- app.bookRepository.importDownloadedBook(
- file = tempFile,
- originalFileName = buildDownloadFileName(book),
- remoteId = book.id,
- coverImage = book.coverImage
- )
- } finally {
- tempFile.takeIf(File::exists)?.delete()
- }
- }
- }
-
- if (_binding == null) {
- return@launch
- }
-
- result.onSuccess { downloadedBook ->
- setDownloadStatus(getString(R.string.qbooks_download_complete), false)
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.dialog_ready_title)
- .setMessage(getString(R.string.dialog_book_added_message, book.title))
- .setPositiveButton(R.string.action_open) { _, _ -> openDownloadedBook(downloadedBook) }
- .setNegativeButton(R.string.action_ok, null)
- .show()
- }.onFailure { exception ->
- setDownloadStatus("", false)
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.dialog_error_title)
- .setMessage(getString(R.string.dialog_download_book_failed, exception.message ?: "Unknown error"))
- .setPositiveButton(R.string.action_ok, null)
- .show()
- }
- }
+ private fun applyCatalogPage(
+ page: com.aletheia.app.model.CatalogPage,
+ reset: Boolean,
+ pageIndex: Int,
+ generation: Int
+ ) {
+ if (reset) booksAdapter.submitList(page.books) else booksAdapter.appendOrUpdate(page.books)
+ loadMetadataAndCovers(page.books, generation)
+ nextPageUrl = page.nextPageUrl
+ hasMoreBooks = !page.nextPageUrl.isNullOrBlank()
+ currentPage = pageIndex + 1
+ renderLoaded()
}
- private fun openDownloadedBook(book: Book) {
- startActivity(
- Intent(requireContext(), ReaderActivity::class.java)
- .putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id)
- )
- }
-
- private fun renderState(showConfigured: Boolean, showError: Boolean, errorMessage: String?) {
- binding.searchCard.visibility = if (isConfigured) View.VISIBLE else View.GONE
- binding.qbooksActionsRow.visibility =
- if (showConfigured && !showError) View.VISIBLE else View.GONE
- binding.notConfiguredCard.visibility = if (!isConfigured && !showError) View.VISIBLE else View.GONE
- binding.errorCard.visibility = if (showError) View.VISIBLE else View.GONE
- binding.swipeRefresh.visibility = if (showConfigured && !showError) View.VISIBLE else View.GONE
- if (!showConfigured || showError) {
- setCatalogStatus(null)
- }
- binding.emptyResultsCard.visibility =
- if (showConfigured && !showError && !isLoading && booksAdapter.itemCount == 0) View.VISIBLE else View.GONE
- binding.errorText.text = errorMessage.orEmpty()
- }
-
- private fun setLoadedCatalogStatus() {
- val itemCount = booksAdapter.itemCount
+ private fun renderLoaded() {
+ binding.errorCard.visibility = View.GONE
+ binding.notConfiguredCard.visibility = View.GONE
+ binding.searchCard.visibility = View.VISIBLE
+ binding.qbooksActionsRow.visibility = View.VISIBLE
+ binding.swipeRefresh.visibility = View.VISIBLE
+ val count = booksAdapter.itemCount
val query = currentSearchQuery()
- val detail = when {
- itemCount == 0 && query.isNotBlank() -> {
- getString(R.string.qbooks_catalog_empty_query, query)
- }
- itemCount == 0 -> {
- getString(R.string.qbooks_catalog_empty)
- }
- query.isNotBlank() -> {
- resources.getQuantityString(
- R.plurals.qbooks_catalog_search_summary,
- itemCount,
- itemCount,
- query
- )
- }
- else -> {
- resources.getQuantityString(R.plurals.qbooks_catalog_summary, itemCount, itemCount)
- }
- }
- val meta = when {
- itemCount == 0 -> queryMetaText(query)
- hasMoreBooks -> mergeOverviewMeta(queryMetaText(query), getString(R.string.qbooks_catalog_more_hint))
- else -> mergeOverviewMeta(queryMetaText(query), getString(R.string.qbooks_catalog_end_hint))
- }
-
+ binding.emptyResultsCard.visibility = if (count == 0) View.VISIBLE else View.GONE
binding.emptyResultsDetailText.text = if (query.isBlank()) {
getString(R.string.status_empty_qbooks_subtitle)
} else {
getString(R.string.qbooks_catalog_empty_query, query)
}
- setCatalogOverview(
- title = getString(R.string.qbooks_catalog_overview_title),
- detail = detail,
- meta = meta
+ val detail = if (query.isBlank()) {
+ resources.getQuantityString(R.plurals.qbooks_catalog_summary, count, count)
+ } else {
+ resources.getQuantityString(R.plurals.qbooks_catalog_search_summary, count, count, query)
+ }
+ val hint = if (hasMoreBooks) R.string.qbooks_catalog_more_hint else R.string.qbooks_catalog_end_hint
+ setOverview(
+ getString(R.string.qbooks_catalog_overview_title),
+ detail,
+ if (count == 0) null else getString(hint)
)
}
- private fun setCatalogStatus(status: String?) {
- setCatalogOverview(
- title = if (status.isNullOrBlank()) null else getString(R.string.qbooks_catalog_overview_title),
- detail = status
- )
+ private fun renderError(message: String) {
+ booksAdapter.submitList(emptyList())
+ binding.searchCard.visibility = if (isConfigured) View.VISIBLE else View.GONE
+ binding.qbooksActionsRow.visibility = View.GONE
+ binding.swipeRefresh.visibility = View.GONE
+ binding.notConfiguredCard.visibility = if (isConfigured) View.GONE else View.VISIBLE
+ binding.errorCard.visibility = if (isConfigured) View.VISIBLE else View.GONE
+ binding.errorText.text = message
+ binding.catalogOverviewCard.visibility = View.GONE
+ binding.emptyResultsCard.visibility = View.GONE
+ binding.swipeRefresh.isRefreshing = false
+ isLoading = false
}
- private fun setCatalogOverview(title: String?, detail: String?, meta: String? = null) {
- val visible = !title.isNullOrBlank() || !detail.isNullOrBlank() || !meta.isNullOrBlank()
- binding.catalogOverviewCard.visibility = if (visible) View.VISIBLE else View.GONE
- binding.catalogOverviewTitleText.text = title.orEmpty()
- binding.catalogOverviewTitleText.visibility = if (title.isNullOrBlank()) View.GONE else View.VISIBLE
- binding.catalogStatusText.text = detail.orEmpty()
- binding.catalogStatusText.visibility = if (detail.isNullOrBlank()) View.GONE else View.VISIBLE
+ private fun setOverview(title: String, detail: String, meta: String?) {
+ binding.catalogOverviewCard.visibility = View.VISIBLE
+ binding.catalogOverviewTitleText.text = title
+ binding.catalogStatusText.text = detail
binding.catalogOverviewMetaText.text = meta.orEmpty()
binding.catalogOverviewMetaText.visibility = if (meta.isNullOrBlank()) View.GONE else View.VISIBLE
}
+ private fun loadMetadataAndCovers(books: List, generation: Int) {
+ books.forEach { book ->
+ coverJobs += viewLifecycleOwner.lifecycleScope.launch {
+ var enriched = app.qBooksService.enrichBookMetadata(book)
+ if (_binding != null && generation == requestGeneration && enriched != book) {
+ booksAdapter.updateBook(enriched)
+ }
+ val cover = if (enriched.coverImage == null && !enriched.coverUrl.isNullOrBlank()) {
+ app.qBooksService.fetchCoverImage(enriched)
+ } else {
+ enriched.coverImage
+ }
+ if (cover != null && _binding != null && generation == requestGeneration) {
+ enriched = enriched.copy(coverImage = cover)
+ booksAdapter.updateBook(enriched)
+ }
+ }
+ }
+ }
+
+ private fun downloadBook(book: QBooksBook) {
+ if (!activeDownloads.add(book.id)) return
+ setDownloadStatus(getString(R.string.qbooks_download_status, book.title), true)
+ viewLifecycleOwner.lifecycleScope.launch {
+ val result = withContext(Dispatchers.IO) {
+ runCatching {
+ app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it }
+ val progress: (Int) -> Unit = { value ->
+ view?.post { setDownloadStatus(getString(R.string.qbooks_download_progress, value.toDouble()), true) }
+ }
+ val temp = when (val download = app.qBooksService.downloadBook(
+ book,
+ app.cacheDir,
+ progress
+ )) {
+ is Result.Success -> download.value
+ is Result.Failure -> error(download.message)
+ }
+ try {
+ app.bookRepository.importDownloadedBook(
+ temp,
+ buildDownloadFileName(book),
+ book.id,
+ book.coverImage
+ )
+ } finally {
+ temp.takeIf(File::exists)?.delete()
+ }
+ }
+ }
+ activeDownloads.remove(book.id)
+ if (_binding == null) return@launch
+ result.onSuccess { downloaded ->
+ setDownloadStatus(getString(R.string.qbooks_download_complete), false)
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.dialog_ready_title)
+ .setMessage(getString(R.string.dialog_book_added_message, book.title))
+ .setPositiveButton(R.string.action_read) { _, _ -> openDownloadedBook(downloaded) }
+ .setNegativeButton(R.string.action_ok, null)
+ .show()
+ }.onFailure {
+ setDownloadStatus("", false)
+ showMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
+ }
+ }
+ }
+
+ private fun openBookDetails(book: QBooksBook) {
+ startActivity(BookDetailActivity.createIntent(requireContext(), book))
+ }
+
+ private fun openDownloadedBook(book: Book) {
+ startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
+ }
+
+ private fun openProfile() {
+ (activity as? MainActivity)?.selectTab(R.id.nav_profile)
+ }
+
private fun currentSearchQuery(): String = binding.searchInput.text?.toString().orEmpty().trim()
- private fun queryMetaText(query: String): String? =
- query.takeIf { it.isNotBlank() }?.let { getString(R.string.qbooks_catalog_query_meta, it) }
-
- private fun mergeOverviewMeta(first: String?, second: String): String =
- listOfNotNull(first, second).joinToString(separator = " · ")
-
private fun setDownloadStatus(status: String, inProgress: Boolean) {
binding.downloadStatusCard.visibility = if (status.isBlank()) View.GONE else View.VISIBLE
binding.downloadStatusText.text = status
@@ -404,7 +363,7 @@ class QBooksLibraryFragment : Fragment() {
companion object {
private const val PAGE_SIZE = 20
-
+ private const val SEARCH_CACHE_MAX_AGE_MS = 60 * 60 * 1000L
fun newInstance() = QBooksLibraryFragment()
}
}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderActivity.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderActivity.kt
index f03d4ca..00d9a65 100644
--- a/app/src/main/java/com/aletheia/app/ui/reader/ReaderActivity.kt
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderActivity.kt
@@ -1,208 +1,220 @@
package com.aletheia.app.ui.reader
-import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
import android.content.Context
+import android.content.Intent
+import android.content.pm.ActivityInfo
import android.content.res.ColorStateList
import android.graphics.Color
-import android.graphics.Rect
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.LayerDrawable
+import android.graphics.drawable.StateListDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
-import android.text.InputType
+import android.os.SystemClock
+import android.util.Log
+import android.view.KeyEvent
+import android.view.Gravity
import android.view.MotionEvent
import android.view.View
-import android.view.inputmethod.EditorInfo
-import android.view.inputmethod.InputMethodManager
+import android.view.ViewConfiguration
+import android.view.ViewGroup
import android.view.WindowManager
-import android.widget.EditText
import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
import android.widget.Toast
-import android.webkit.JavascriptInterface
-import android.webkit.WebResourceRequest
-import android.webkit.WebResourceResponse
-import android.webkit.WebView
-import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.children
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
-import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.webkit.WebViewAssetLoader
import com.aletheia.app.AletheiaApplication
import com.aletheia.app.R
-import com.aletheia.app.data.SettingsRepository
import com.aletheia.app.databinding.ActivityReaderBinding
import com.aletheia.app.model.Book
import com.aletheia.app.model.ReaderChapterItem
import com.aletheia.app.model.ReadingBookmark
import com.aletheia.app.model.ReadingNote
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.aletheia.app.ui.reader.tts.ReaderSpeechController
+import com.aletheia.app.ui.reader.tts.ReaderSpeechSettingsDialog
+import com.google.android.material.button.MaterialButton
import java.io.File
-import java.text.SimpleDateFormat
-import java.util.Date
-import java.util.Locale
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.withContext
-import org.json.JSONArray
-import org.json.JSONObject
-import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.roundToInt
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
class ReaderActivity : AppCompatActivity() {
-
private lateinit var binding: ActivityReaderBinding
+ private lateinit var webController: ReaderWebController
+ private lateinit var speechController: ReaderSpeechController
private val app by lazy { application as AletheiaApplication }
+ private val paginationCache by lazy { ReaderPaginationCache(this) }
private lateinit var chapterAdapter: ChapterAdapter
private lateinit var bookmarkAdapter: BookmarkAdapter
- private lateinit var noteAdapter: ReadingNoteAdapter
+ private lateinit var quoteAdapter: ReadingNoteAdapter
private var currentBook: Book? = null
- private var isReaderReady = false
- private var isBookLoaded = false
- private var isReaderContentReady = false
- private var isMenuVisible = false
- private var isReaderHudVisible = false
- private var isChapterListVisible = false
- private var isBookmarkListVisible = false
- private var isNoteListVisible = false
- private var isBookSearchInProgress = false
-
- private var fontSize = 18
- private var fontFamily = "serif"
- private var readerTheme = "sepia"
- private var brightness = 100.0
-
- private var currentChapterTitle = ""
- private var chapterCurrentPage = 1
- private var chapterTotalPages = 1
- private var currentPage = 1
- private var totalPages = 100
- private var currentBookSearchQuery = ""
- private var bookSearchResultCount = 0
- private var bookSearchCurrentIndex = -1
-
- private var lastPersistedProgress = -1.0
- private var lastPersistedCfi = ""
- private var lastPersistedChapter = ""
- private var lastPersistedCurrentPage = -1
- private var lastPersistedTotalPages = -1
- private var lastPersistedAt = 0L
- private var menuOverlayBasePadding = EdgeInsets()
- private var readerHudBaseMargins = EdgeInsets()
- private var currentSafeInsets = EdgeInsets()
-
- private val availableFonts = listOf(
- "serif",
- "sans-serif",
- "monospace",
- "Georgia",
- "Palatino",
- "Times New Roman",
- "Arial",
- "Verdana",
- "Courier New"
- )
-
- private val availableFontSizes = listOf(12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40)
- private val allChapters = mutableListOf()
- private val currentBookmarks = mutableListOf()
- private val currentNotes = mutableListOf()
- private var pendingNotesExportText: String? = null
- private val hideReaderHudRunnable = Runnable { hideReaderHudForReading() }
- private val finishHideReaderHudRunnable = Runnable { finishHideReaderHudForReading() }
-
- private val exportNotesLauncher = registerForActivityResult(
- ActivityResultContracts.CreateDocument("text/plain")
- ) { uri ->
- handleNotesExportUri(uri)
+ private var preferences = ReaderPreferences()
+ private var position = ReaderPosition()
+ private var restoredPosition: ReaderPosition? = null
+ private var chapters: List = emptyList()
+ private var bookmarks: List = emptyList()
+ private var quotes: List = emptyList()
+ private var currentSelection: ReaderSelection? = null
+ private var pendingHighlightColor: String? = null
+ private var contentsTab = ContentsTab.TOC
+ private var bookReady = false
+ private var updatingSettingsViews = false
+ private var updatingProgressSlider = false
+ private var progressSaveJob: Job? = null
+ private var settingsSaveJob: Job? = null
+ private var orientationJob: Job? = null
+ private var finishJob: Job? = null
+ private var paginationCacheKey: String? = null
+ private var finishingReader = false
+ private var retryingReader = false
+ private var readerTouchActive = false
+ private var readerTouchMoved = false
+ private var readerTouchDownX = 0f
+ private var readerTouchDownY = 0f
+ private var readerTouchDownAt = 0L
+ private var lastNativeControlsToggleAt = 0L
+ private val readerTapSlop by lazy(LazyThreadSafetyMode.NONE) {
+ ViewConfiguration.get(this).scaledTouchSlop.toFloat()
}
+ private var pageHeaderBasePadding = Insets()
+ private var controlsBasePadding = Insets()
+ private var settingsBasePadding = Insets()
+ private var contentsBasePadding = Insets()
+ private var selectionBasePadding = Insets()
+ private var noteBasePadding = Insets()
+ private var safeInsets = Insets()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- binding = ActivityReaderBinding.inflate(layoutInflater)
- setContentView(binding.root)
-
- setupReadingWindow()
- setupChapterList()
- setupBookmarks()
- setupNotes()
- setupControls()
- setupWebView()
-
- binding.readerHudChapterText.text = getString(R.string.reader_waiting_for_book)
- binding.progressBadgeText.text = getString(R.string.reader_progress_percent, 0)
- binding.readerHudChapterProgressText.text = getString(R.string.reader_chapter_progress_pending)
- binding.readerProgressIndicator.progress = 0
- binding.readerHudCard.alpha = 0f
- binding.readerHudCard.visibility = View.GONE
-
- onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
- override fun handleOnBackPressed() {
- if (isMenuVisible) {
- hideMenu()
- return
- }
-
- lifecycleScope.launch {
- saveCurrentProgress(force = true)
- finish()
- }
- }
- })
-
- val bookId = intent.getLongExtra(EXTRA_BOOK_ID, 0L)
- if (bookId == 0L) {
+ if (savedInstanceState?.getBoolean(STATE_FINISHING, false) == true) {
+ finishingReader = true
finish()
return
}
+ binding = ActivityReaderBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ preferences = ReaderPreferences.fromStateJson(savedInstanceState?.getString(STATE_PREFERENCES))
+ ?: ReaderPreferences.from(app.settingsRepository)
+ restoredPosition = savedInstanceState?.restoreReaderPosition()
+ setupWindow()
+ setupLists()
+ setupControls()
+ renderSettings()
+ applyNativePreferences()
+
+ webController = ReaderWebController(this, binding.readerWebView, ::handleReaderEvent)
+ speechController = ReaderSpeechController(
+ context = this,
+ scope = lifecycleScope,
+ reader = webController,
+ onStateChanged = ::renderSpeechState,
+ onMessage = { message -> Toast.makeText(this, message, Toast.LENGTH_LONG).show() }
+ )
+ applyRequestedOrientation()
+ showLoading("Подготавливаю книгу…")
+
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() = handleBack()
+ })
+
+ val bookId = intent.getLongExtra(EXTRA_BOOK_ID, 0L)
+ if (bookId <= 0L) {
+ finish()
+ return
+ }
loadBook(bookId)
}
override fun onResume() {
super.onResume()
- enterImmersiveReadingMode()
+ applySystemUi()
}
- override fun onWindowFocusChanged(hasFocus: Boolean) {
- super.onWindowFocusChanged(hasFocus)
- if (hasFocus) {
- enterImmersiveReadingMode()
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putBoolean(STATE_FINISHING, finishingReader)
+ outState.putString(STATE_PREFERENCES, preferences.toStateJson())
+ currentBook?.let { book ->
+ outState.putLong(STATE_BOOK_ID, book.id)
+ outState.putDouble(STATE_PROGRESS, position.progress)
+ outState.putString(STATE_LOCATOR, position.locator)
+ outState.putString(STATE_CHAPTER, position.chapter)
+ outState.putInt(STATE_CURRENT_PAGE, position.currentPage)
+ outState.putInt(STATE_TOTAL_PAGES, position.totalPages)
+ outState.putInt(STATE_CHAPTER_CURRENT_PAGE, position.chapterCurrentPage)
+ outState.putInt(STATE_CHAPTER_TOTAL_PAGES, position.chapterTotalPages)
}
+ super.onSaveInstanceState(outState)
}
override fun onPause() {
- super.onPause()
- lifecycleScope.launch {
- saveCurrentProgress(force = true)
+ if (!finishingReader) {
+ scheduleProgressSave(immediate = true)
+ scheduleSettingsSave(immediate = true)
}
+ super.onPause()
}
override fun onDestroy() {
- binding.root.removeCallbacks(hideReaderHudRunnable)
- binding.root.removeCallbacks(finishHideReaderHudRunnable)
- restoreSystemBars()
- binding.readerWebView.destroy()
- super.onDestroy()
+ try {
+ if (::speechController.isInitialized) speechController.destroy()
+ if (::webController.isInitialized) webController.destroy()
+ } finally {
+ super.onDestroy()
+ }
}
- private fun setupReadingWindow() {
- WindowCompat.setDecorFitsSystemWindows(window, false)
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
- applyLegacyTransparentSystemBars()
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
+ if (bookReady && preferences.volumeButtons && !preferences.verticalScroll) {
+ when (keyCode) {
+ KeyEvent.KEYCODE_VOLUME_DOWN -> {
+ webController.next()
+ return true
+ }
+ KeyEvent.KEYCODE_VOLUME_UP -> {
+ webController.previous()
+ return true
+ }
+ }
}
+ return super.onKeyDown(keyCode, event)
+ }
+ private fun setupWindow() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode =
@@ -210,1436 +222,1554 @@ class ReaderActivity : AppCompatActivity() {
}
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.isNavigationBarContrastEnforced = false
- }
-
- menuOverlayBasePadding = EdgeInsets(
- left = binding.menuOverlayContent.paddingLeft,
- top = binding.menuOverlayContent.paddingTop,
- right = binding.menuOverlayContent.paddingRight,
- bottom = binding.menuOverlayContent.paddingBottom
- )
-
- val readerHudLayoutParams = binding.readerHudCard.layoutParams as FrameLayout.LayoutParams
- readerHudBaseMargins = EdgeInsets(
- left = readerHudLayoutParams.leftMargin,
- top = readerHudLayoutParams.topMargin,
- right = readerHudLayoutParams.rightMargin,
- bottom = readerHudLayoutParams.bottomMargin
- )
+ pageHeaderBasePadding = binding.pageHeader.paddingInsets()
+ controlsBasePadding = binding.readerControlsOverlay.paddingInsets()
+ settingsBasePadding = binding.readerSettingsOverlay.paddingInsets()
+ contentsBasePadding = binding.readerContentsOverlay.paddingInsets()
+ selectionBasePadding = binding.readerSelectionOverlay.paddingInsets()
+ noteBasePadding = binding.noteEditorOverlay.paddingInsets()
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
- val safeInsets = insets.getInsetsIgnoringVisibility(
+ val safe = insets.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
- applySafeInsets(
- EdgeInsets(
- left = safeInsets.left,
- top = safeInsets.top,
- right = safeInsets.right,
- bottom = safeInsets.bottom
- )
+ val status = insets.getInsetsIgnoringVisibility(
+ WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout()
+ )
+ val tappable = insets.getInsetsIgnoringVisibility(
+ WindowInsetsCompat.Type.tappableElement()
+ )
+ val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
+ safeInsets = Insets(safe.left, safe.top, safe.right, safe.bottom)
+ binding.readerStatusBarBackground.updateLayoutParams {
+ height = status.top
+ }
+ binding.readerNavigationBarBottomBackground.updateLayoutParams {
+ height = tappable.bottom
+ }
+ binding.readerNavigationBarLeftBackground.updateLayoutParams {
+ width = tappable.left
+ }
+ binding.readerNavigationBarRightBackground.updateLayoutParams {
+ width = tappable.right
+ }
+ binding.pageHeader.applyInsets(pageHeaderBasePadding, safe.left, 0, safe.right, 0)
+ updateReaderViewportMargins()
+ binding.readerControlsOverlay.applyInsets(
+ controlsBasePadding,
+ safe.left,
+ safe.top,
+ safe.right,
+ safe.bottom
+ )
+ binding.readerSettingsOverlay.applyInsets(
+ settingsBasePadding,
+ safe.left,
+ safe.top,
+ safe.right,
+ safe.bottom
+ )
+ binding.readerContentsOverlay.applyInsets(
+ contentsBasePadding,
+ safe.left,
+ safe.top,
+ safe.right,
+ safe.bottom
+ )
+ binding.readerSelectionOverlay.applyInsets(
+ selectionBasePadding,
+ safe.left,
+ safe.top,
+ safe.right,
+ safe.bottom
+ )
+ binding.noteEditorOverlay.applyInsets(
+ noteBasePadding,
+ safe.left,
+ safe.top,
+ safe.right,
+ safe.bottom.coerceAtLeast(ime.bottom)
)
insets
}
ViewCompat.requestApplyInsets(binding.root)
- binding.root.post { applyMenuSheetHeight() }
- enterImmersiveReadingMode()
}
- private fun enterImmersiveReadingMode() {
- WindowCompat.getInsetsController(window, binding.root).apply {
- systemBarsBehavior =
- WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
- hide(WindowInsetsCompat.Type.systemBars())
+ private fun setupLists() {
+ chapterAdapter = ChapterAdapter { chapter ->
+ webController.goToChapter(chapter.href)
+ closeContents()
}
- }
-
- private fun restoreSystemBars() {
- WindowCompat.getInsetsController(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
- WindowCompat.setDecorFitsSystemWindows(window, true)
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- window.attributes = window.attributes.apply {
- layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
- }
- }
- }
-
- private fun applySafeInsets(insets: EdgeInsets) {
- if (currentSafeInsets == insets) {
- return
- }
-
- currentSafeInsets = insets
- binding.menuOverlayContent.updatePadding(
- left = menuOverlayBasePadding.left + insets.left,
- top = menuOverlayBasePadding.top + insets.top,
- right = menuOverlayBasePadding.right + insets.right,
- bottom = menuOverlayBasePadding.bottom + insets.bottom
- )
-
- val readerHudLayoutParams = binding.readerHudCard.layoutParams as FrameLayout.LayoutParams
- readerHudLayoutParams.leftMargin = readerHudBaseMargins.left + insets.left
- readerHudLayoutParams.topMargin = readerHudBaseMargins.top + insets.top
- readerHudLayoutParams.rightMargin = readerHudBaseMargins.right + insets.right
- readerHudLayoutParams.bottomMargin = readerHudBaseMargins.bottom + insets.bottom
- binding.readerHudCard.layoutParams = readerHudLayoutParams
-
- applyMenuSheetHeight()
- sendSafeAreaInsetsToReader()
- }
-
- private fun applyMenuSheetHeight() {
- val rootHeight = binding.root.height.takeIf { it > 0 } ?: resources.displayMetrics.heightPixels
- val reservedTop = currentSafeInsets.top + dp(96)
- val availableHeight = (rootHeight - reservedTop - currentSafeInsets.bottom).coerceAtLeast(dp(360))
- val targetHeight = minOf((rootHeight * 0.62f).roundToInt(), availableHeight).coerceAtLeast(dp(360))
- val layoutParams = binding.menuScroll.layoutParams
- if (layoutParams.height != targetHeight) {
- layoutParams.height = targetHeight
- binding.menuScroll.layoutParams = layoutParams
- }
- }
-
- private fun sendSafeAreaInsetsToReader() {
- val contentInsets = currentSafeInsets.copy(
- bottom = currentSafeInsets.bottom + if (!isMenuVisible && isReaderHudVisible) {
- dp(READER_HUD_CONTENT_INSET_DP)
- } else {
- 0
- }
- )
- val cssInsets = contentInsets.toCssInsets()
- binding.readerWebView.evaluateJavascript(
- """
- if (window.setSafeAreaInsets) {
- window.setSafeAreaInsets(${cssInsets.left}, ${cssInsets.top}, ${cssInsets.right}, ${cssInsets.bottom});
- }
- """.trimIndent(),
- null
- )
- }
-
- @Suppress("DEPRECATION")
- private fun applyLegacyTransparentSystemBars() {
- window.statusBarColor = Color.TRANSPARENT
- window.navigationBarColor = Color.TRANSPARENT
- }
-
- private fun setupChapterList() {
- chapterAdapter = ChapterAdapter { chapter -> goToChapter(chapter.href) }
- binding.chaptersRecycler.layoutManager = LinearLayoutManager(this)
- binding.chaptersRecycler.adapter = chapterAdapter
- }
-
- private fun setupBookmarks() {
bookmarkAdapter = BookmarkAdapter(
- onBookmarkClicked = ::goToBookmark,
+ onBookmarkClicked = { bookmark ->
+ bookmark.cfi?.takeIf(String::isNotBlank)?.let(webController::goToLocator)
+ ?: webController.goToProgress(bookmark.progress)
+ closeContents()
+ },
onDeleteClicked = ::deleteBookmark
)
+ quoteAdapter = ReadingNoteAdapter(
+ onNoteClicked = { note ->
+ note.cfi?.takeIf(String::isNotBlank)?.let(webController::goToLocator)
+ ?: webController.goToProgress(note.progress)
+ closeContents()
+ },
+ onDeleteClicked = ::deleteQuote
+ )
+
+ binding.tocRecycler.layoutManager = LinearLayoutManager(this)
+ binding.tocRecycler.adapter = chapterAdapter
binding.bookmarksRecycler.layoutManager = LinearLayoutManager(this)
binding.bookmarksRecycler.adapter = bookmarkAdapter
- renderBookmarkSection()
- }
-
- private fun setupNotes() {
- noteAdapter = ReadingNoteAdapter(
- onNoteClicked = ::goToNote,
- onDeleteClicked = ::deleteNote
- )
- binding.notesRecycler.layoutManager = LinearLayoutManager(this)
- binding.notesRecycler.adapter = noteAdapter
- renderNoteSection()
+ binding.quotesRecycler.layoutManager = LinearLayoutManager(this)
+ binding.quotesRecycler.adapter = quoteAdapter
}
private fun setupControls() {
- binding.menuOverlay.setOnClickListener { hideMenu() }
- binding.menuPanel.setOnClickListener { }
- binding.readerHudCard.setOnClickListener { toggleMenu() }
- binding.backButton.setOnClickListener {
- lifecycleScope.launch {
- saveCurrentProgress(force = true)
- finish()
+ binding.readerWebView.setOnTouchListener { _, event ->
+ observeNativeReaderTap(event)
+ false
+ }
+ binding.readerBackButton.setOnClickListener { finishReader() }
+ binding.readerVoiceButton.setOnClickListener {
+ if (!bookReady) {
+ Toast.makeText(this, "Дождитесь загрузки книги", Toast.LENGTH_SHORT).show()
+ } else {
+ speechController.toggle()
}
}
- binding.hideMenuButton.setOnClickListener { hideMenu() }
-
- binding.themeWarmButton.setOnClickListener { changeTheme("sepia") }
- binding.themeLightButton.setOnClickListener { changeTheme("light") }
- binding.themeDarkButton.setOnClickListener { changeTheme("dark") }
-
- binding.quickChaptersButton.setOnClickListener { openChaptersQuickAction() }
- binding.quickBookmarkButton.setOnClickListener { addCurrentBookmark() }
- binding.quickNoteButton.setOnClickListener { addCurrentNote() }
- binding.quickSearchButton.setOnClickListener { openSearchQuickAction() }
-
- binding.decreaseFontButton.setOnClickListener {
- val index = availableFontSizes.indexOf(fontSize)
- if (index > 0) {
- changeFontSize(availableFontSizes[index - 1])
- }
- }
- binding.increaseFontButton.setOnClickListener {
- val index = availableFontSizes.indexOf(fontSize)
- if (index in 0 until availableFontSizes.lastIndex) {
- changeFontSize(availableFontSizes[index + 1])
- }
- }
-
- binding.fontFamilyDropdown.setSimpleItems(availableFonts.toTypedArray())
- binding.fontFamilyDropdown.setOnItemClickListener { _, _, position, _ ->
- changeFontFamily(availableFonts[position])
- }
-
- binding.brightnessSlider.addOnChangeListener { _, value, _ ->
- changeBrightness(value.toDouble())
- }
-
- binding.toggleChaptersButton.setOnClickListener {
- if (allChapters.isNotEmpty()) {
- isChapterListVisible = !isChapterListVisible
- if (isChapterListVisible) {
- isBookmarkListVisible = false
- isNoteListVisible = false
- }
- if (!isChapterListVisible) {
- binding.chapterSearchInput.setText("")
- }
- renderChapterSection()
- renderNoteSection()
- renderBookmarkSection()
- }
- }
-
- binding.addBookmarkButton.setOnClickListener { addCurrentBookmark() }
- binding.toggleBookmarksButton.setOnClickListener {
- if (currentBookmarks.isNotEmpty()) {
- isBookmarkListVisible = !isBookmarkListVisible
- if (isBookmarkListVisible) {
- isChapterListVisible = false
- isNoteListVisible = false
- binding.chapterSearchInput.setText("")
- }
- renderChapterSection()
- renderNoteSection()
- renderBookmarkSection()
- }
- }
-
- binding.addNoteButton.setOnClickListener { addCurrentNote() }
- binding.exportNotesButton.setOnClickListener { exportNotes() }
- binding.toggleNotesButton.setOnClickListener {
- if (currentNotes.isNotEmpty()) {
- isNoteListVisible = !isNoteListVisible
- if (isNoteListVisible) {
- isChapterListVisible = false
- isBookmarkListVisible = false
- binding.chapterSearchInput.setText("")
- }
- renderChapterSection()
- renderBookmarkSection()
- renderNoteSection()
- }
- }
-
- binding.chapterSearchInput.doAfterTextChanged {
- applyChapterFilter()
- }
- binding.clearChapterSearchButton.setOnClickListener {
- binding.chapterSearchInput.setText("")
- }
-
- binding.runBookSearchButton.setOnClickListener { performBookSearch() }
- binding.bookSearchInput.setOnEditorActionListener { _, actionId, _ ->
- if (actionId == EditorInfo.IME_ACTION_SEARCH) {
- performBookSearch()
+ binding.readerVoiceButton.setOnLongClickListener {
+ if (::speechController.isInitialized) {
+ ReaderSpeechSettingsDialog.show(this, speechController)
true
} else {
false
}
}
- binding.bookSearchInput.doAfterTextChanged { text ->
- if (text.isNullOrBlank() && currentBookSearchQuery.isNotBlank()) {
- clearBookSearch(resetText = false)
- } else {
- renderBookSearchSection()
+ binding.readerSettingsButton.setOnClickListener { openSettings() }
+ binding.readerContentsButton.setOnClickListener { openContents() }
+ binding.readerBookmarkButton.setOnClickListener { toggleBookmark() }
+ binding.readerRetryButton.setOnClickListener {
+ retryReader()
+ }
+
+ binding.readerProgressSlider.addOnChangeListener { _, value, fromUser ->
+ if (fromUser && !updatingProgressSlider && bookReady) {
+ webController.goToProgress((value / 100f).toDouble())
}
}
- binding.prevSearchResultButton.setOnClickListener { moveToBookSearchResult(previous = true) }
- binding.nextSearchResultButton.setOnClickListener { moveToBookSearchResult(previous = false) }
- binding.clearBookSearchButton.setOnClickListener { clearBookSearch(resetText = true) }
- renderBookSearchSection()
- }
- private fun openChaptersQuickAction() {
- isChapterListVisible = allChapters.isNotEmpty()
- isBookmarkListVisible = false
- isNoteListVisible = false
- renderChapterSection()
- renderBookmarkSection()
- renderNoteSection()
- scrollMenuTo(binding.chaptersCard)
- }
+ binding.settingsScrim.setOnClickListener { closeSettings() }
+ binding.settingsCloseButton.setOnClickListener { closeSettings() }
+ binding.allSettingsButton.setOnClickListener { showFullSettings(true) }
+ binding.settingsBackButton.setOnClickListener { showFullSettings(false) }
- private fun openSearchQuickAction() {
- isChapterListVisible = false
- isBookmarkListVisible = false
- isNoteListVisible = false
- renderChapterSection()
- renderBookmarkSection()
- renderNoteSection()
- renderBookSearchSection()
- scrollMenuTo(binding.bookSearchCard)
- binding.bookSearchInput.post {
- binding.bookSearchInput.requestFocus()
- showKeyboard(binding.bookSearchInput)
+ binding.themeLightButton.setOnClickListener {
+ updatePreferences { copy(theme = ReaderPreferences.THEME_LIGHT) }
}
- }
-
- private fun scrollMenuTo(target: View) {
- binding.menuScroll.post {
- val targetRect = Rect()
- target.getDrawingRect(targetRect)
- binding.menuScroll.offsetDescendantRectToMyCoords(target, targetRect)
- binding.menuScroll.smoothScrollTo(0, (targetRect.top - dp(12)).coerceAtLeast(0))
+ binding.themeSepiaButton.setOnClickListener {
+ updatePreferences { copy(theme = ReaderPreferences.THEME_SEPIA) }
+ }
+ binding.themeDarkButton.setOnClickListener {
+ updatePreferences { copy(theme = ReaderPreferences.THEME_DARK) }
+ }
+ binding.fullThemeLightButton.setOnClickListener {
+ updatePreferences { copy(theme = ReaderPreferences.THEME_LIGHT) }
+ }
+ binding.fullThemeSepiaButton.setOnClickListener {
+ updatePreferences { copy(theme = ReaderPreferences.THEME_SEPIA) }
+ }
+ binding.fullThemeDarkButton.setOnClickListener {
+ updatePreferences { copy(theme = ReaderPreferences.THEME_DARK) }
+ }
+ binding.fontSizeMinusButton.setOnClickListener {
+ updatePreferences { copy(fontSize = (fontSize - 2).coerceAtLeast(12)) }
+ }
+ binding.fontSizePlusButton.setOnClickListener {
+ updatePreferences { copy(fontSize = (fontSize + 2).coerceAtMost(42)) }
+ }
+ binding.fullFontSizeMinusButton.setOnClickListener {
+ updatePreferences { copy(fontSize = (fontSize - 2).coerceAtLeast(12)) }
+ }
+ binding.fullFontSizePlusButton.setOnClickListener {
+ updatePreferences { copy(fontSize = (fontSize + 2).coerceAtMost(42)) }
+ }
+ binding.fontFamilyDropdown.setSimpleItems(ReaderPreferences.FONT_NAMES.toTypedArray())
+ binding.fontFamilyDropdown.setOnItemClickListener { _, _, index, _ ->
+ if (!updatingSettingsViews) {
+ updatePreferences { copy(fontName = ReaderPreferences.FONT_NAMES[index]) }
+ }
+ }
+ binding.fullFontFamilyDropdown.setSimpleItems(ReaderPreferences.FONT_NAMES.toTypedArray())
+ binding.fullFontFamilyDropdown.setOnItemClickListener { _, _, index, _ ->
+ if (!updatingSettingsViews) {
+ updatePreferences { copy(fontName = ReaderPreferences.FONT_NAMES[index]) }
+ }
+ }
+ binding.brightnessSlider.addOnChangeListener { _, value, fromUser ->
+ if (fromUser && !updatingSettingsViews) {
+ updatePreferences { copy(brightness = value.roundToInt().coerceIn(10, 100)) }
+ }
+ }
+ binding.fullBrightnessSlider.addOnChangeListener { _, value, fromUser ->
+ if (fromUser && !updatingSettingsViews) {
+ updatePreferences { copy(brightness = value.roundToInt().coerceIn(10, 100)) }
+ }
+ }
+ binding.systemBrightnessSwitch.setOnCheckedChangeListener { _, checked ->
+ if (!updatingSettingsViews) updatePreferences { copy(systemBrightness = checked) }
+ }
+ binding.fullSystemBrightnessSwitch.setOnCheckedChangeListener { _, checked ->
+ if (!updatingSettingsViews) updatePreferences { copy(systemBrightness = checked) }
+ }
+ binding.verticalScrollSwitch.setOnCheckedChangeListener { _, checked ->
+ if (!updatingSettingsViews) updatePreferences { copy(verticalScroll = checked) }
+ }
+ binding.fullVerticalScrollSwitch.setOnCheckedChangeListener { _, checked ->
+ if (!updatingSettingsViews) updatePreferences { copy(verticalScroll = checked) }
}
- }
- private fun showKeyboard(target: View) {
- val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
- inputMethodManager?.showSoftInput(target, InputMethodManager.SHOW_IMPLICIT)
- }
-
- @Suppress("DEPRECATION")
- @SuppressLint("ClickableViewAccessibility", "SetJavaScriptEnabled")
- private fun setupWebView() {
- val assetLoader = WebViewAssetLoader.Builder()
- .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this))
- .build()
-
- binding.readerWebView.setBackgroundColor(Color.TRANSPARENT)
- binding.readerWebView.setOnTouchListener { view, event ->
- if (event.action == MotionEvent.ACTION_UP && isBookLoaded && !isMenuVisible) {
- val width = view.width.coerceAtLeast(1)
- if (event.x < width * READER_EDGE_TAP_FRACTION ||
- event.x > width * (1f - READER_EDGE_TAP_FRACTION)
- ) {
- showReaderHudTemporarily()
+ val orientations = arrayOf("Автоматически", "Портретная", "Альбомная")
+ binding.orientationDropdown.setSimpleItems(orientations)
+ binding.orientationDropdown.setOnItemClickListener { _, _, index, _ ->
+ if (!updatingSettingsViews) {
+ updatePreferences {
+ copy(
+ orientation = when (index) {
+ 1 -> ReaderPreferences.ORIENTATION_PORTRAIT
+ 2 -> ReaderPreferences.ORIENTATION_LANDSCAPE
+ else -> ReaderPreferences.ORIENTATION_AUTO
+ }
+ )
}
}
- false
}
- binding.readerWebView.settings.apply {
- javaScriptEnabled = true
- domStorageEnabled = true
- allowFileAccess = false
- allowContentAccess = false
- allowFileAccessFromFileURLs = false
- allowUniversalAccessFromFileURLs = false
- loadsImagesAutomatically = true
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- safeBrowsingEnabled = true
- }
+ binding.alignJustifyButton.setOnClickListener {
+ updatePreferences { copy(textAlign = ReaderPreferences.ALIGN_JUSTIFY) }
+ }
+ binding.alignLeftButton.setOnClickListener {
+ updatePreferences { copy(textAlign = ReaderPreferences.ALIGN_LEFT) }
+ }
+ binding.lineHeightMinusButton.setOnClickListener {
+ updatePreferences { copy(lineHeight = (lineHeight - 0.1).coerceAtLeast(1.1)) }
+ }
+ binding.lineHeightPlusButton.setOnClickListener {
+ updatePreferences { copy(lineHeight = (lineHeight + 0.1).coerceAtMost(2.4)) }
+ }
+ binding.marginMinusButton.setOnClickListener {
+ updatePreferences { copy(margin = (margin - 4).coerceAtLeast(8)) }
+ }
+ binding.marginPlusButton.setOnClickListener {
+ updatePreferences { copy(margin = (margin + 4).coerceAtMost(72)) }
}
- binding.readerWebView.addJavascriptInterface(ReaderBridge(), "AndroidBridge")
- binding.readerWebView.webViewClient = object : WebViewClient() {
- override fun shouldInterceptRequest(
- view: WebView?,
- request: WebResourceRequest?
- ): WebResourceResponse? =
- request?.url?.let(assetLoader::shouldInterceptRequest)
- ?: super.shouldInterceptRequest(view, request)
- override fun onPageFinished(view: WebView?, url: String?) {
- super.onPageFinished(view, url)
- sendSafeAreaInsetsToReader()
+ binding.volumeButtonsSwitch.setPreferenceListener { copy(volumeButtons = it) }
+ binding.invertZonesSwitch.setPreferenceListener { copy(invertZones = it) }
+ binding.brightnessGestureSwitch.setPreferenceListener { copy(brightnessGesture = it) }
+ binding.keepScreenOnSwitch.setPreferenceListener { copy(keepScreenOn = it) }
+ binding.showTitleSwitch.setPreferenceListener { copy(showTitle = it) }
+ binding.showStatusSwitch.setPreferenceListener { copy(showStatus = it) }
+ binding.pageTurnTapSwipeButton.setOnClickListener {
+ updatePreferences { copy(pageTurnMode = ReaderPreferences.PAGE_TURN_TAP_SWIPE) }
+ }
+ binding.pageTurnSwipeButton.setOnClickListener {
+ updatePreferences { copy(pageTurnMode = ReaderPreferences.PAGE_TURN_SWIPE) }
+ }
+ binding.pageTurnTapButton.setOnClickListener {
+ updatePreferences { copy(pageTurnMode = ReaderPreferences.PAGE_TURN_TAP) }
+ }
+
+ binding.contentsCloseButton.setOnClickListener { closeContents() }
+ binding.tabTocButton.setOnClickListener { showContentsTab(ContentsTab.TOC) }
+ binding.tabBookmarksButton.setOnClickListener { showContentsTab(ContentsTab.BOOKMARKS) }
+ binding.tabQuotesButton.setOnClickListener { showContentsTab(ContentsTab.QUOTES) }
+
+ binding.selectionScrim.setOnClickListener { closeSelection() }
+ binding.selectionCloseButton.setOnClickListener { closeSelection() }
+ binding.selectionShareButton.setOnClickListener { shareSelection() }
+ binding.selectionCopyButton.setOnClickListener { copySelection() }
+ binding.selectionNoteButton.setOnClickListener { openNoteEditor() }
+ binding.colorNoneButton.setOnClickListener { selectHighlightColor(null) }
+ binding.colorYellowButton.setOnClickListener { selectHighlightColor(COLOR_YELLOW) }
+ binding.colorGreenButton.setOnClickListener { selectHighlightColor(COLOR_GREEN) }
+ binding.colorBlueButton.setOnClickListener { selectHighlightColor(COLOR_BLUE) }
+ binding.colorPurpleButton.setOnClickListener { selectHighlightColor(COLOR_PURPLE) }
+ binding.colorPinkButton.setOnClickListener { selectHighlightColor(COLOR_PINK) }
+ binding.saveQuoteButton.setOnClickListener { saveAnnotation("", ReadingNote.KIND_QUOTE) }
+
+ binding.noteCancelButton.setOnClickListener { closeNoteEditor() }
+ binding.noteSaveButton.setOnClickListener {
+ val note = binding.noteInput.text?.toString().orEmpty().trim()
+ if (note.isBlank()) {
+ binding.noteInput.error = "Введите текст заметки"
+ } else {
+ saveAnnotation(note, ReadingNote.KIND_NOTE)
}
}
- binding.readerWebView.loadUrl(READER_ASSET_URL)
+ }
+
+ private fun renderSpeechState(state: ReaderSpeechController.State) {
+ val active = state == ReaderSpeechController.State.PLAYING ||
+ state == ReaderSpeechController.State.INITIALIZING
+ binding.readerVoiceButton.isSelected = active
+ binding.readerVoiceButton.contentDescription = if (active) {
+ "Приостановить озвучивание"
+ } else {
+ "Слушать книгу. Удерживайте для настройки скорости и интонации"
+ }
}
private fun loadBook(bookId: Long) {
lifecycleScope.launch {
- val book = withContext(Dispatchers.IO) { app.bookRepository.getBookById(bookId) }
- if (book == null) {
- MaterialAlertDialogBuilder(this@ReaderActivity)
- .setTitle(R.string.dialog_error_title)
- .setMessage(R.string.dialog_book_file_missing)
- .setPositiveButton(R.string.action_ok) { _, _ -> finish() }
- .setOnDismissListener { finish() }
- .show()
+ val data = withContext(Dispatchers.IO) {
+ app.readerPersistenceMutex.withLock {
+ val book = app.bookRepository.getBookById(bookId)
+ Triple(
+ book,
+ book?.let { app.bookRepository.getBookmarks(it.id) }.orEmpty(),
+ book?.let { app.bookRepository.getNotes(it.id) }.orEmpty()
+ )
+ }
+ }
+ val book = data.first
+ if (book == null || !File(book.filePath).isFile) {
+ showError("Файл книги не найден", retryAllowed = false)
return@launch
}
-
currentBook = book
- fontSize = app.bookRepository.getDefaultFontSize()
- fontFamily = app.bookRepository.getDefaultFontFamily()
- readerTheme = app.bookRepository.getDefaultTheme()
- brightness = app.bookRepository.getDefaultBrightness()
- currentPage = if (book.currentPage > 0) book.currentPage else 1
- totalPages = if (book.totalPages > 0) book.totalPages else 100
- currentChapterTitle = book.lastChapter.orEmpty()
-
- rememberPersistedProgress(
- progress = book.readingProgress,
- cfi = book.lastCfi,
+ bookmarks = data.second
+ quotes = data.third
+ val databasePosition = ReaderPosition(
+ progress = book.readingProgress.coerceIn(0.0, 1.0),
+ locator = book.lastCfi,
chapter = book.lastChapter,
- progressCurrentPage = book.currentPage,
- progressTotalPages = book.totalPages
+ currentPage = book.currentPage.coerceAtLeast(1),
+ totalPages = book.totalPages.coerceAtLeast(1)
)
-
- binding.readerTitleText.text = book.title
- binding.fontFamilyDropdown.setText(fontFamily, false)
- binding.brightnessSlider.value = brightness.toFloat()
- syncThemeButtons()
- updateProgressViews()
- renderChapterSection()
- loadBookmarks()
- loadNotes()
- maybeLoadBookIntoWebView()
+ position = app.readerStateCoordinator.latest(book.id)?.toReaderPosition()
+ ?: restoredPosition
+ ?: databasePosition
+ restoredPosition = null
+ publishPosition()
+ binding.pageHeaderTitle.text = book.title
+ binding.readerBottomTitle.text = book.title
+ binding.contentsBookTitle.text = listOf(book.author, book.title)
+ .filter(String::isNotBlank)
+ .joinToString(". ")
+ renderProgress()
+ renderCollections()
+ val cacheKey = paginationCache.key(
+ file = File(book.filePath),
+ preferences = preferences,
+ viewportWidth = resources.displayMetrics.widthPixels,
+ viewportHeight = resources.displayMetrics.heightPixels
+ )
+ paginationCacheKey = cacheKey
+ val cachedLocations = withContext(Dispatchers.IO) { paginationCache.read(cacheKey) }
+ runCatching {
+ webController.loadBook(
+ file = File(book.filePath),
+ format = book.format,
+ locator = position.locator,
+ progress = position.progress,
+ cachedLocations = cachedLocations,
+ preferences = preferences,
+ title = book.title
+ )
+ }.onFailure { error ->
+ showError(
+ error.message ?: "Не удалось подготовить книгу",
+ retryAllowed = false
+ )
+ }
}
}
- private fun maybeLoadBookIntoWebView() {
- if (!isReaderReady || isBookLoaded) {
- return
- }
-
- val book = currentBook ?: return
- lifecycleScope.launch {
- if (!File(book.filePath).exists()) {
- MaterialAlertDialogBuilder(this@ReaderActivity)
- .setTitle(R.string.dialog_error_title)
- .setMessage(R.string.dialog_book_file_missing)
- .setPositiveButton(R.string.action_ok) { _, _ -> finish() }
- .show()
- return@launch
+ private fun handleReaderEvent(event: ReaderEvent) {
+ if (finishingReader) return
+ when (event) {
+ ReaderEvent.ShellReady -> binding.readerLoadingText.text = "Открываю книгу…"
+ ReaderEvent.ToggleControls -> {
+ val sinceNativeToggle = SystemClock.uptimeMillis() - lastNativeControlsToggleAt
+ if (sinceNativeToggle > NATIVE_TAP_DUPLICATE_WINDOW_MS) toggleControls()
}
-
- try {
- evalJsSuspend("window._bkChunks = [];")
- withContext(Dispatchers.IO) {
- File(book.filePath).inputStream().use { input ->
- val buffer = ByteArray(BASE64_RAW_CHUNK_SIZE)
- while (true) {
- val read = input.read(buffer)
- if (read <= 0) {
- break
- }
- val chunk = android.util.Base64.encodeToString(
- buffer.copyOf(read),
- android.util.Base64.NO_WRAP
- )
- evalJsSuspend("window._bkChunks.push('$chunk');")
- }
+ ReaderEvent.Navigation -> Unit
+ is ReaderEvent.BookReady -> {
+ bookReady = true
+ if (event.totalPages > 1) position = position.copy(totalPages = event.totalPages)
+ publishPosition()
+ hideLoading()
+ renderProgress()
+ webController.setHighlights(quotes)
+ }
+ is ReaderEvent.Progress -> {
+ position = ReaderPosition(
+ progress = event.progress,
+ locator = event.locator ?: position.locator,
+ chapter = event.chapter ?: position.chapter,
+ currentPage = event.currentPage,
+ totalPages = event.totalPages,
+ chapterCurrentPage = event.chapterCurrentPage,
+ chapterTotalPages = event.chapterTotalPages
+ )
+ publishPosition()
+ renderProgress()
+ updateCurrentChapter()
+ scheduleProgressSave(immediate = false)
+ }
+ is ReaderEvent.Toc -> {
+ chapters = event.chapters.mapIndexed { index, item ->
+ ReaderChapterItem(item.label, item.href, index)
+ }
+ updateCurrentChapter()
+ renderCollections()
+ }
+ is ReaderEvent.Selection -> {
+ val snapshot = event.snapshot?.copy(
+ progress = position.progress,
+ currentPage = position.currentPage,
+ totalPages = position.totalPages,
+ chapter = position.chapter
+ )
+ currentSelection = snapshot
+ if (snapshot == null) {
+ binding.readerSelectionOverlay.isVisible = false
+ } else {
+ pendingHighlightColor = null
+ renderSelectionPalette()
+ binding.readerSelectionOverlay.isVisible = true
+ binding.readerControlsOverlay.isVisible = false
+ applySystemUi()
+ }
+ }
+ is ReaderEvent.ExternalLink -> openExternalLink(event.url)
+ is ReaderEvent.BrightnessDelta -> {
+ if (preferences.brightnessGesture && !preferences.systemBrightness) {
+ updatePreferences {
+ copy(brightness = (brightness + event.delta).roundToInt().coerceIn(10, 100))
}
}
-
- val lastCfi = escapeJs(book.lastCfi.orEmpty())
- val locations = escapeJs(book.locations.orEmpty())
- val format = book.format.lowercase(Locale.US)
- evalJsSuspend("window.loadBookFromBase64(window._bkChunks.join(''), '$format', '$lastCfi', '$locations');")
- evalJsSuspend("delete window._bkChunks;")
-
- isBookLoaded = true
- applyReaderPreferences()
- renderQuickActions()
- renderBookSearchSection()
- } catch (exception: Exception) {
- MaterialAlertDialogBuilder(this@ReaderActivity)
- .setTitle(R.string.dialog_error_title)
- .setMessage(getString(R.string.dialog_load_book_failed, exception.message ?: "Unknown error"))
- .setPositiveButton(R.string.action_ok) { _, _ -> finish() }
- .show()
}
- }
- }
-
- private fun applyReaderPreferences() {
- lifecycleScope.launch {
- evalJsSuspend("window.setFontSize($fontSize)")
- evalJsSuspend("window.setFontFamily('${escapeJs(fontFamily)}')")
- evalJsSuspend("window.setReaderTheme('${escapeJs(readerTheme)}')")
- evalJsSuspend("window.setBrightness(${brightness.toStringInvariant()})")
- }
- }
-
- private suspend fun saveCurrentProgress(force: Boolean) {
- if (!isBookLoaded) {
- return
- }
-
- val result = evalJsSuspend("window.getProgress()") ?: return
- if (result == "null" || result == "undefined" || result == "{}") {
- return
- }
-
- val json = runCatching { JSONObject(unescapeJsResult(result)) }.getOrNull() ?: return
- val progress = json.optDouble("progress", 0.0)
- val cfi = json.optString("cfi").takeIf { it.isNotBlank() }
- val page = json.optInt("currentPage", currentPage)
- val pages = json.optInt("totalPages", totalPages)
- maybePersistProgress(progress, cfi, currentChapterTitle, page, pages, force)
- }
-
- private fun toggleMenu() {
- isMenuVisible = !isMenuVisible
- if (!isMenuVisible) {
- isChapterListVisible = false
- isBookmarkListVisible = false
- isNoteListVisible = false
- }
- renderMenuVisibility()
- }
-
- private fun hideMenu() {
- isMenuVisible = false
- isChapterListVisible = false
- isBookmarkListVisible = false
- isNoteListVisible = false
- renderMenuVisibility()
- }
-
- private fun renderMenuVisibility() {
- if (isMenuVisible) {
- applyMenuSheetHeight()
- syncThemeButtons()
- binding.root.removeCallbacks(hideReaderHudRunnable)
- binding.root.removeCallbacks(finishHideReaderHudRunnable)
- binding.readerHudCard.animate().cancel()
- isReaderHudVisible = false
- binding.readerHudCard.alpha = 0f
- binding.readerHudCard.visibility = View.GONE
- } else {
- showReaderHudTemporarily()
- }
- binding.menuOverlay.visibility = if (isMenuVisible) android.view.View.VISIBLE else android.view.View.GONE
- sendSafeAreaInsetsToReader()
- renderQuickActions()
- renderChapterSection()
- renderBookSearchSection()
- renderNoteSection()
- renderBookmarkSection()
- }
-
- private fun showReaderHudTemporarily() {
- if (isMenuVisible || !isReaderContentReady) {
- return
- }
-
- binding.root.removeCallbacks(hideReaderHudRunnable)
- binding.root.removeCallbacks(finishHideReaderHudRunnable)
- binding.readerHudCard.animate().cancel()
-
- val wasHidden = !isReaderHudVisible || binding.readerHudCard.visibility != View.VISIBLE
- isReaderHudVisible = true
- binding.readerHudCard.visibility = View.VISIBLE
- if (wasHidden) {
- binding.readerHudCard.alpha = 0f
- binding.readerHudCard.animate()
- .alpha(1f)
- .setDuration(READER_HUD_FADE_MS)
- .start()
- sendSafeAreaInsetsToReader()
- } else {
- binding.readerHudCard.alpha = 1f
- }
-
- if (isBookLoaded) {
- binding.root.postDelayed(hideReaderHudRunnable, READER_HUD_AUTO_HIDE_DELAY_MS)
- }
- }
-
- private fun hideReaderHudForReading() {
- if (isMenuVisible || !isBookLoaded || !isReaderHudVisible) {
- return
- }
-
- binding.readerHudCard.animate().cancel()
- binding.readerHudCard.animate()
- .alpha(0f)
- .setDuration(READER_HUD_FADE_MS)
- .start()
- binding.root.postDelayed(finishHideReaderHudRunnable, READER_HUD_FADE_MS)
- }
-
- private fun finishHideReaderHudForReading() {
- if (isMenuVisible || !isBookLoaded) {
- return
- }
-
- isReaderHudVisible = false
- binding.readerHudCard.visibility = View.GONE
- binding.readerHudCard.alpha = 0f
- sendSafeAreaInsetsToReader()
- }
-
- private fun renderQuickActions() {
- binding.quickChaptersButton.isEnabled = allChapters.isNotEmpty()
- binding.quickBookmarkButton.isEnabled = isBookLoaded
- binding.quickNoteButton.isEnabled = isBookLoaded
- binding.quickSearchButton.isEnabled = isBookLoaded
- }
-
- private fun showInitialReaderHudIfNeeded() {
- if (isReaderContentReady) {
- return
- }
-
- isReaderContentReady = true
- showReaderHudTemporarily()
- }
-
- private fun renderChapterSection() {
- binding.chaptersContainer.visibility =
- if (isMenuVisible && isChapterListVisible) android.view.View.VISIBLE else android.view.View.GONE
- binding.toggleChaptersButton.text = getString(
- if (isChapterListVisible) R.string.action_hide_chapters else R.string.action_show_chapters
- )
- binding.toggleChaptersButton.isEnabled = allChapters.isNotEmpty()
- binding.chapterSummaryText.text = currentChapterSummary()
- renderQuickActions()
- applyChapterFilter()
- }
-
- private fun applyChapterFilter() {
- val query = binding.chapterSearchInput.text?.toString().orEmpty().trim().lowercase(Locale.getDefault())
- val filtered = if (query.isBlank()) {
- allChapters
- } else {
- allChapters.filter { it.searchText.contains(query) }
- }
-
- chapterAdapter.submitList(filtered)
- binding.chapterEmptyText.visibility =
- if (isMenuVisible && isChapterListVisible && allChapters.isNotEmpty() && filtered.isEmpty()) {
- android.view.View.VISIBLE
- } else {
- android.view.View.GONE
- }
- binding.chapterResultsText.text = currentChapterResultsText(filtered.size)
- }
-
- private fun renderBookSearchSection() {
- binding.bookSearchStatusText.text = when {
- isBookSearchInProgress -> getString(R.string.reader_search_book_searching)
- currentBookSearchQuery.isBlank() -> getString(R.string.reader_search_book_empty)
- bookSearchResultCount <= 0 -> getString(R.string.reader_search_book_no_results)
- else -> getString(
- R.string.reader_search_book_results,
- bookSearchResultCount,
- (bookSearchCurrentIndex + 1).coerceIn(1, bookSearchResultCount)
- )
- }
-
- val hasResults = bookSearchResultCount > 0 && !isBookSearchInProgress
- binding.runBookSearchButton.isEnabled = isBookLoaded && !isBookSearchInProgress
- binding.prevSearchResultButton.isEnabled = hasResults
- binding.nextSearchResultButton.isEnabled = hasResults
- binding.clearBookSearchButton.isEnabled =
- !isBookSearchInProgress &&
- (currentBookSearchQuery.isNotBlank() || binding.bookSearchInput.text?.isNotBlank() == true)
- }
-
- private fun performBookSearch() {
- if (!isBookLoaded) {
- return
- }
-
- val query = binding.bookSearchInput.text?.toString().orEmpty().trim()
- if (query.isBlank()) {
- clearBookSearch(resetText = false)
- return
- }
-
- currentBookSearchQuery = query
- bookSearchResultCount = 0
- bookSearchCurrentIndex = -1
- isBookSearchInProgress = true
- renderBookSearchSection()
-
- lifecycleScope.launch {
- evalJsSuspend("window.searchBook && window.searchBook('${escapeJs(query)}')")
- }
- }
-
- private fun clearBookSearch(resetText: Boolean) {
- currentBookSearchQuery = ""
- bookSearchResultCount = 0
- bookSearchCurrentIndex = -1
- isBookSearchInProgress = false
- if (resetText && binding.bookSearchInput.text?.isNotEmpty() == true) {
- binding.bookSearchInput.setText("")
- }
- renderBookSearchSection()
-
- if (isBookLoaded) {
- lifecycleScope.launch {
- evalJsSuspend("window.clearBookSearch && window.clearBookSearch()")
- }
- }
- }
-
- private fun moveToBookSearchResult(previous: Boolean) {
- if (bookSearchResultCount <= 0 || isBookSearchInProgress) {
- return
- }
-
- lifecycleScope.launch {
- val functionName = if (previous) "previousBookSearchResult" else "nextBookSearchResult"
- evalJsSuspend("window.$functionName && window.$functionName()")
- }
- }
-
- private fun handleBookSearchResults(data: JSONObject) {
- currentBookSearchQuery = data.optString("query", currentBookSearchQuery)
- bookSearchResultCount = data.optInt("total", 0).coerceAtLeast(0)
- bookSearchCurrentIndex = data.optInt("currentIndex", -1)
- isBookSearchInProgress = data.optBoolean("searching", false)
- renderBookSearchSection()
- }
-
- private fun loadNotes() {
- val bookId = currentBook?.id ?: return
- lifecycleScope.launch(Dispatchers.IO) {
- val notes = app.bookRepository.getNotes(bookId)
- withContext(Dispatchers.Main) {
- currentNotes.clear()
- currentNotes.addAll(notes)
- noteAdapter.submitList(notes)
- renderNoteSection()
- }
- }
- }
-
- private fun renderNoteSection() {
- binding.noteSummaryText.text = if (currentNotes.isEmpty()) {
- getString(R.string.reader_note_summary_empty)
- } else {
- getString(R.string.reader_note_summary_count, currentNotes.size)
- }
- binding.toggleNotesButton.text = getString(
- if (isNoteListVisible) R.string.action_hide_notes else R.string.action_show_notes
- )
- binding.toggleNotesButton.isEnabled = currentNotes.isNotEmpty()
- binding.addNoteButton.isEnabled = isBookLoaded
- binding.exportNotesButton.isEnabled = currentNotes.isNotEmpty()
- binding.notesContainer.visibility =
- if (isMenuVisible && isNoteListVisible) android.view.View.VISIBLE else android.view.View.GONE
- binding.noteEmptyText.visibility =
- if (isMenuVisible && isNoteListVisible && currentNotes.isEmpty()) {
- android.view.View.VISIBLE
- } else {
- android.view.View.GONE
- }
- }
-
- private fun addCurrentNote() {
- val book = currentBook ?: return
- lifecycleScope.launch {
- val snapshot = readCurrentNoteSnapshot()
- if (snapshot == null) {
- Toast.makeText(
- this@ReaderActivity,
- R.string.reader_bookmark_position_missing,
- Toast.LENGTH_SHORT
- ).show()
- return@launch
- }
- showAddNoteDialog(book.id, snapshot)
- }
- }
-
- private fun showAddNoteDialog(bookId: Long, snapshot: NoteSnapshot) {
- val input = EditText(this).apply {
- hint = getString(R.string.dialog_add_note_hint)
- minLines = 3
- inputType = InputType.TYPE_CLASS_TEXT or
- InputType.TYPE_TEXT_FLAG_MULTI_LINE or
- InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
- }
-
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.dialog_add_note_title)
- .setMessage(snapshot.selectedText?.takeIf { it.isNotBlank() })
- .setView(input)
- .setNegativeButton(R.string.action_cancel, null)
- .setPositiveButton(R.string.action_save) { _, _ ->
- val noteText = input.text?.toString().orEmpty().trim()
- val selectedText = snapshot.selectedText?.trim()?.takeIf { it.isNotBlank() }
- if (noteText.isBlank() && selectedText.isNullOrBlank()) {
- Toast.makeText(this, R.string.reader_note_text_required, Toast.LENGTH_SHORT).show()
- return@setPositiveButton
+ is ReaderEvent.PaginationCache -> {
+ val key = paginationCacheKey ?: return
+ lifecycleScope.launch(Dispatchers.IO) {
+ runCatching { paginationCache.write(key, event.locations) }
}
+ }
+ is ReaderEvent.Error -> {
+ val fatal = !bookReady || event.stage in FATAL_ERROR_STAGES
+ if (fatal) {
+ bookReady = false
+ showError(event.message, retryAllowed = event.recoverable)
+ } else {
+ Toast.makeText(this, event.message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ is ReaderEvent.Search -> Unit
+ }
+ }
- lifecycleScope.launch {
- val saved = withContext(Dispatchers.IO) {
- app.bookRepository.addNote(
- bookId = bookId,
- cfi = snapshot.cfi,
- progress = snapshot.progress,
- currentPage = snapshot.currentPage,
- totalPages = snapshot.totalPages,
- chapter = snapshot.chapter,
- selectedText = selectedText,
- noteText = noteText.ifBlank { getString(R.string.reader_note_position_only) }
+ private fun observeNativeReaderTap(event: MotionEvent) {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ readerTouchActive = event.pointerCount == 1
+ readerTouchMoved = false
+ readerTouchDownX = event.x
+ readerTouchDownY = event.y
+ readerTouchDownAt = event.eventTime
+ }
+ MotionEvent.ACTION_POINTER_DOWN,
+ MotionEvent.ACTION_CANCEL -> {
+ readerTouchActive = false
+ readerTouchMoved = false
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (readerTouchActive && (
+ abs(event.x - readerTouchDownX) > readerTapSlop ||
+ abs(event.y - readerTouchDownY) > readerTapSlop
)
- }
- currentNotes.add(0, saved)
- noteAdapter.submitList(currentNotes)
- isNoteListVisible = true
- isBookmarkListVisible = false
- isChapterListVisible = false
- renderChapterSection()
- renderBookmarkSection()
- renderNoteSection()
- Toast.makeText(this@ReaderActivity, R.string.reader_note_added, Toast.LENGTH_SHORT).show()
+ ) {
+ readerTouchMoved = true
}
}
- .show()
- }
-
- private fun goToNote(note: ReadingNote) {
- val cfi = note.cfi?.takeIf { it.isNotBlank() } ?: return
- lifecycleScope.launch {
- evalJsSuspend("window.goToPosition('${escapeJs(cfi)}')")
- maybePersistProgress(
- progress = note.progress,
- cfi = cfi,
- chapter = note.chapterTitle,
- progressCurrentPage = note.currentPage,
- progressTotalPages = note.totalPages,
- force = true
- )
- hideMenu()
- }
- }
-
- private fun deleteNote(note: ReadingNote) {
- lifecycleScope.launch(Dispatchers.IO) {
- app.bookRepository.deleteNote(note)
- val notes = currentBook?.id?.let(app.bookRepository::getNotes).orEmpty()
- withContext(Dispatchers.Main) {
- currentNotes.clear()
- currentNotes.addAll(notes)
- noteAdapter.submitList(notes)
- if (notes.isEmpty()) {
- isNoteListVisible = false
+ MotionEvent.ACTION_UP -> {
+ val elapsed = event.eventTime - readerTouchDownAt
+ val normalizedX = event.x / binding.readerWebView.width.coerceAtLeast(1)
+ val modalVisible = binding.readerLoadingOverlay.isVisible ||
+ binding.readerSettingsOverlay.isVisible ||
+ binding.readerContentsOverlay.isVisible ||
+ binding.readerSelectionOverlay.isVisible ||
+ binding.noteEditorOverlay.isVisible
+ val shouldToggle = readerTouchActive &&
+ !readerTouchMoved &&
+ elapsed in 0..NATIVE_TAP_MAX_DURATION_MS &&
+ normalizedX in READER_CENTER_ZONE_START..READER_CENTER_ZONE_END &&
+ bookReady &&
+ !finishingReader &&
+ currentSelection == null &&
+ !modalVisible
+ readerTouchActive = false
+ readerTouchMoved = false
+ if (shouldToggle) {
+ lastNativeControlsToggleAt = SystemClock.uptimeMillis()
+ toggleControls()
}
- renderNoteSection()
- Toast.makeText(this@ReaderActivity, R.string.reader_note_deleted, Toast.LENGTH_SHORT).show()
}
}
}
- private fun exportNotes() {
+ private fun toggleControls() {
+ if (binding.readerSettingsOverlay.isVisible || binding.readerContentsOverlay.isVisible ||
+ binding.readerSelectionOverlay.isVisible || binding.noteEditorOverlay.isVisible
+ ) return
+ binding.readerControlsOverlay.isVisible = !binding.readerControlsOverlay.isVisible
+ renderChromeVisibility()
+ }
+
+ private fun renderChromeVisibility() {
+ binding.pageHeader.isVisible = preferences.showTitle &&
+ !binding.readerControlsOverlay.isVisible &&
+ !binding.readerContentsOverlay.isVisible
+ updateReaderViewportMargins()
+ applySystemUi()
+ }
+
+ private fun openSettings() {
+ showFullSettings(false)
+ renderSettings()
+ binding.readerSettingsOverlay.isVisible = true
+ applySystemUi()
+ }
+
+ private fun closeSettings() {
+ binding.readerSettingsOverlay.isVisible = false
+ renderChromeVisibility()
+ }
+
+ private fun showFullSettings(full: Boolean) {
+ binding.settingsSheetGuideline.setGuidelinePercent(0.529f)
+ binding.settingsHeader.isVisible = false
+ binding.quickSettingsContainer.isVisible = !full
+ binding.fullSettingsContainer.isVisible = full
+ if (full) binding.fullSettingsContainer.post { binding.fullSettingsContainer.scrollTo(0, 0) }
+ }
+
+ private fun openContents() {
+ binding.readerControlsOverlay.isVisible = false
+ binding.readerSettingsOverlay.isVisible = false
+ binding.readerContentsOverlay.isVisible = true
+ showContentsTab(contentsTab)
+ renderCollections()
+ renderChromeVisibility()
+ }
+
+ private fun closeContents() {
+ binding.readerContentsOverlay.isVisible = false
+ binding.readerControlsOverlay.isVisible = true
+ renderChromeVisibility()
+ }
+
+ private fun showContentsTab(tab: ContentsTab) {
+ contentsTab = tab
+ binding.tocRecycler.isVisible = tab == ContentsTab.TOC && chapters.isNotEmpty()
+ binding.bookmarksRecycler.isVisible = tab == ContentsTab.BOOKMARKS && bookmarks.isNotEmpty()
+ binding.quotesRecycler.isVisible = tab == ContentsTab.QUOTES && quotes.isNotEmpty()
+ binding.tocEmptyText.isVisible = tab == ContentsTab.TOC && chapters.isEmpty()
+ binding.bookmarksEmptyText.isVisible = tab == ContentsTab.BOOKMARKS && bookmarks.isEmpty()
+ binding.quotesEmptyText.isVisible = tab == ContentsTab.QUOTES && quotes.isEmpty()
+ binding.tabTocButton.isSelected = tab == ContentsTab.TOC
+ binding.tabBookmarksButton.isSelected = tab == ContentsTab.BOOKMARKS
+ binding.tabQuotesButton.isSelected = tab == ContentsTab.QUOTES
+ val activeColor = Color.parseColor(PRIMARY_GRAY)
+ val inactiveColor = Color.parseColor(SECONDARY_ON_LIGHT)
+ binding.tabTocButton.setTextColor(if (tab == ContentsTab.TOC) activeColor else inactiveColor)
+ binding.tabBookmarksButton.setTextColor(if (tab == ContentsTab.BOOKMARKS) activeColor else inactiveColor)
+ binding.tabQuotesButton.setTextColor(if (tab == ContentsTab.QUOTES) activeColor else inactiveColor)
+ }
+
+ private fun renderCollections() {
+ chapterAdapter.submitList(chapters)
+ bookmarkAdapter.submitList(bookmarks)
+ quoteAdapter.submitList(quotes)
+ showContentsTab(contentsTab)
+ syncBookmarkButton()
+ }
+
+ private fun updateCurrentChapter() {
+ val current = position.chapter.orEmpty().trim()
+ chapters.forEach { chapter -> chapter.isCurrent = current.isNotBlank() && chapter.label.trim() == current }
+ chapterAdapter.submitList(chapters)
+ }
+
+ private fun renderProgress() {
+ val remaining = (position.chapterTotalPages - position.chapterCurrentPage).coerceAtLeast(0)
+ binding.pageHeaderRemaining.text = "Еще $remaining стр."
+ binding.readerBottomChapter.text = position.chapter.orEmpty().ifBlank { " " }
+ binding.readerPageCount.text = "${position.currentPage} из ${position.totalPages}"
+ binding.readerChapterRemaining.text = "Еще $remaining стр."
+ updatingProgressSlider = true
+ binding.readerProgressSlider.value = (position.progress * 100).toFloat().coerceIn(0f, 100f)
+ updatingProgressSlider = false
+ binding.readerPersistentProgress.setProgressCompat(
+ (position.progress * 10_000).roundToInt().coerceIn(0, 10_000),
+ true
+ )
+ syncBookmarkButton()
+ }
+
+ private fun toggleBookmark() {
val book = currentBook ?: return
- if (currentNotes.isEmpty()) {
- Toast.makeText(this, R.string.reader_no_notes, Toast.LENGTH_SHORT).show()
- return
- }
-
- pendingNotesExportText = buildNotesExportText(book, currentNotes)
- exportNotesLauncher.launch(buildNotesExportFileName(book))
- }
-
- private fun handleNotesExportUri(uri: Uri?) {
- val exportText = pendingNotesExportText ?: return
- pendingNotesExportText = null
- if (uri == null) {
- return
- }
-
- runCatching {
- contentResolver.openOutputStream(uri)?.use { output ->
- output.write(exportText.toByteArray(Charsets.UTF_8))
- } ?: error("Output stream is unavailable")
- }.onSuccess {
- Toast.makeText(this, R.string.reader_note_exported, Toast.LENGTH_SHORT).show()
- }.onFailure { error ->
+ val existing = currentBookmark()
+ lifecycleScope.launch {
+ bookmarks = withContext(Dispatchers.IO) {
+ if (existing != null) {
+ app.bookRepository.deleteBookmark(existing)
+ } else {
+ app.bookRepository.addBookmark(
+ bookId = book.id,
+ cfi = position.locator,
+ progress = position.progress,
+ currentPage = position.currentPage,
+ totalPages = position.totalPages,
+ chapter = position.chapter,
+ label = position.chapter.orEmpty().ifBlank { "Страница ${position.currentPage}" }
+ )
+ }
+ app.bookRepository.getBookmarks(book.id)
+ }
+ renderCollections()
Toast.makeText(
- this,
- getString(R.string.reader_note_export_failed, error.message ?: "Unknown error"),
- Toast.LENGTH_LONG
+ this@ReaderActivity,
+ if (existing == null) "Закладка сохранена" else "Закладка удалена",
+ Toast.LENGTH_SHORT
).show()
}
}
- private fun buildNotesExportText(book: Book, notes: List): String = buildString {
- appendLine(getString(R.string.reader_note_export_title, book.title))
- appendLine(getString(R.string.reader_note_export_author, book.author))
- appendLine(getString(R.string.reader_note_export_created, formatExportTimestamp(System.currentTimeMillis())))
- appendLine()
-
- notes.asReversed().forEachIndexed { index, note ->
- appendLine(getString(R.string.reader_note_export_item_title, index + 1))
- appendLine(getString(R.string.reader_note_export_created, formatExportTimestamp(note.createdAt)))
- note.chapterTitle?.takeIf { it.isNotBlank() }?.let { chapter ->
- appendLine(getString(R.string.reader_note_export_chapter, chapter))
- }
- appendLine(
- getString(
- R.string.reader_note_export_position,
- note.progressPercent,
- note.currentPage.coerceAtLeast(0),
- note.totalPages.coerceAtLeast(0)
- )
- )
- note.selectedText?.takeIf { it.isNotBlank() }?.let { quote ->
- appendLine(getString(R.string.reader_note_export_quote))
- appendLine(quote)
- }
- appendLine(getString(R.string.reader_note_export_comment))
- appendLine(note.noteText)
- appendLine()
- }
- }
-
- private fun buildNotesExportFileName(book: Book): String {
- val safeTitle = book.title
- .ifBlank { "book" }
- .replace(Regex("[\\\\/:*?\"<>|]+"), "_")
- .take(80)
- .trim()
- .ifBlank { "book" }
- return "aletheia-notes-$safeTitle.txt"
- }
-
- private fun formatExportTimestamp(value: Long): String =
- SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.forLanguageTag("ru")).format(Date(value))
-
- private suspend fun readCurrentNoteSnapshot(): NoteSnapshot? {
- val result = evalJsSuspend(
- "window.getSelectionSnapshot ? window.getSelectionSnapshot() : window.getProgress()"
- ) ?: return null
- if (result == "null" || result == "undefined" || result == "{}") {
- return null
- }
-
- val json = runCatching { JSONObject(unescapeJsResult(result)) }.getOrNull() ?: return null
- val progress = json.optDouble("progress", 0.0).coerceIn(0.0, 1.0)
- val cfi = json.optString("cfi").takeIf { it.isNotBlank() }
- val selectedText = json.optString("selectedText").takeIf { it.isNotBlank() }
- val page = json.optInt("currentPage", currentPage).takeIf { it > 0 } ?: currentPage
- val pages = json.optInt("totalPages", totalPages).takeIf { it > 0 } ?: totalPages
- val chapter = json.optString("chapter").takeIf { it.isNotBlank() }
- ?: currentChapterTitle.takeIf { it.isNotBlank() }
-
- return NoteSnapshot(
- cfi = cfi,
- progress = progress,
- currentPage = page,
- totalPages = pages,
- chapter = chapter,
- selectedText = selectedText
- )
- }
-
- private fun loadBookmarks() {
- val bookId = currentBook?.id ?: return
- lifecycleScope.launch(Dispatchers.IO) {
- val bookmarks = app.bookRepository.getBookmarks(bookId)
- withContext(Dispatchers.Main) {
- currentBookmarks.clear()
- currentBookmarks.addAll(bookmarks)
- bookmarkAdapter.submitList(bookmarks)
- renderBookmarkSection()
- }
- }
- }
-
- private fun renderBookmarkSection() {
- binding.bookmarkSummaryText.text = if (currentBookmarks.isEmpty()) {
- getString(R.string.reader_bookmark_summary_empty)
+ private fun currentBookmark(): ReadingBookmark? = bookmarks.firstOrNull { bookmark ->
+ val bookmarkLocator = bookmark.cfi?.takeIf(String::isNotBlank)
+ val positionLocator = position.locator?.takeIf(String::isNotBlank)
+ if (bookmarkLocator != null && positionLocator != null) {
+ stableLocatorKey(bookmarkLocator) == stableLocatorKey(positionLocator)
} else {
- getString(R.string.reader_bookmark_summary_count, currentBookmarks.size)
+ abs(bookmark.progress - position.progress) < BOOKMARK_PROGRESS_TOLERANCE
}
- binding.toggleBookmarksButton.text = getString(
- if (isBookmarkListVisible) R.string.action_hide_bookmarks else R.string.action_show_bookmarks
+ }
+
+ private fun stableLocatorKey(rawLocator: String): String {
+ val raw = rawLocator.trim()
+ if (raw.startsWith("epubcfi(")) return "epub:$raw"
+ if (raw.startsWith("fb2:")) {
+ val parts = raw.split(':')
+ if (parts.size in 3..4) {
+ val offset = parts[2].toIntOrNull()
+ val endOffset = parts.getOrNull(3)?.toIntOrNull() ?: offset
+ val sectionId = Uri.decode(parts[1])
+ if (sectionId.isNotBlank() && offset != null && endOffset != null) {
+ return "fb2:$sectionId:$offset:$sectionId:$endOffset"
+ }
+ }
+ return "raw:$raw"
+ }
+ val json = runCatching { JSONObject(raw) }.getOrNull() ?: return "raw:$raw"
+ return when (json.optString("type").lowercase()) {
+ "epub" -> json.optString("cfi")
+ .takeIf(String::isNotBlank)
+ ?.let { "epub:$it" }
+ ?: "raw:$raw"
+ "fb2" -> {
+ val sectionId = json.optString("sectionId").takeIf(String::isNotBlank)
+ ?: return "raw:$raw"
+ val offset = json.optInt("offset", 0).coerceAtLeast(0)
+ val endSectionId = json.optString("endSectionId").ifBlank { sectionId }
+ val endOffset = json.optInt("endOffset", offset).coerceAtLeast(0)
+ "fb2:$sectionId:$offset:$endSectionId:$endOffset"
+ }
+ else -> "raw:$raw"
+ }
+ }
+
+ private fun syncBookmarkButton() {
+ val active = currentBookmark() != null
+ binding.readerBookmarkButton.isSelected = active
+ binding.readerBookmarkButton.imageTintList = ColorStateList.valueOf(
+ if (active) Color.parseColor(ACCENT_COLOR) else readerPalette().chromeIcon
)
- binding.toggleBookmarksButton.isEnabled = currentBookmarks.isNotEmpty()
- binding.bookmarksContainer.visibility =
- if (isMenuVisible && isBookmarkListVisible) android.view.View.VISIBLE else android.view.View.GONE
- binding.bookmarkEmptyText.visibility =
- if (isMenuVisible && isBookmarkListVisible && currentBookmarks.isEmpty()) {
- android.view.View.VISIBLE
- } else {
- android.view.View.GONE
- }
- }
-
- private fun addCurrentBookmark() {
- val book = currentBook ?: return
- lifecycleScope.launch {
- val snapshot = readCurrentProgressSnapshot()
- if (snapshot == null) {
- Toast.makeText(
- this@ReaderActivity,
- R.string.reader_bookmark_position_missing,
- Toast.LENGTH_SHORT
- ).show()
- return@launch
- }
-
- val bookmark = withContext(Dispatchers.IO) {
- app.bookRepository.addBookmark(
- bookId = book.id,
- cfi = snapshot.cfi,
- progress = snapshot.progress,
- currentPage = snapshot.currentPage,
- totalPages = snapshot.totalPages,
- chapter = snapshot.chapter,
- label = snapshot.label
- )
- }
-
- currentBookmarks.add(0, bookmark)
- bookmarkAdapter.submitList(currentBookmarks)
- isBookmarkListVisible = true
- isChapterListVisible = false
- isNoteListVisible = false
- renderChapterSection()
- renderNoteSection()
- renderBookmarkSection()
- Toast.makeText(this@ReaderActivity, R.string.reader_bookmark_added, Toast.LENGTH_SHORT).show()
- }
- }
-
- private fun goToBookmark(bookmark: ReadingBookmark) {
- val cfi = bookmark.cfi?.takeIf { it.isNotBlank() } ?: return
- lifecycleScope.launch {
- evalJsSuspend("window.goToPosition('${escapeJs(cfi)}')")
- maybePersistProgress(
- progress = bookmark.progress,
- cfi = cfi,
- chapter = bookmark.chapterTitle,
- progressCurrentPage = bookmark.currentPage,
- progressTotalPages = bookmark.totalPages,
- force = true
- )
- hideMenu()
- }
}
private fun deleteBookmark(bookmark: ReadingBookmark) {
- lifecycleScope.launch(Dispatchers.IO) {
- app.bookRepository.deleteBookmark(bookmark)
- val bookmarks = currentBook?.id?.let(app.bookRepository::getBookmarks).orEmpty()
- withContext(Dispatchers.Main) {
- currentBookmarks.clear()
- currentBookmarks.addAll(bookmarks)
- bookmarkAdapter.submitList(bookmarks)
- if (bookmarks.isEmpty()) {
- isBookmarkListVisible = false
- }
- renderBookmarkSection()
- Toast.makeText(this@ReaderActivity, R.string.reader_bookmark_deleted, Toast.LENGTH_SHORT).show()
+ val bookId = currentBook?.id ?: return
+ lifecycleScope.launch {
+ bookmarks = withContext(Dispatchers.IO) {
+ app.bookRepository.deleteBookmark(bookmark)
+ app.bookRepository.getBookmarks(bookId)
}
+ renderCollections()
}
}
- private suspend fun readCurrentProgressSnapshot(): BookmarkSnapshot? {
- val result = evalJsSuspend("window.getProgress()") ?: return null
- if (result == "null" || result == "undefined" || result == "{}") {
- return null
- }
-
- val json = runCatching { JSONObject(unescapeJsResult(result)) }.getOrNull() ?: return null
- val progress = json.optDouble("progress", 0.0).coerceIn(0.0, 1.0)
- val cfi = json.optString("cfi").takeIf { it.isNotBlank() }
- if (cfi.isNullOrBlank() && progress <= 0.0) {
- return null
- }
-
- val page = json.optInt("currentPage", currentPage).takeIf { it > 0 } ?: currentPage
- val pages = json.optInt("totalPages", totalPages).takeIf { it > 0 } ?: totalPages
- val chapter = json.optString("chapter").takeIf { it.isNotBlank() }
- ?: currentChapterTitle.takeIf { it.isNotBlank() }
- val pageLabel = if (pages > 1 && page > 0 && pages != 100) {
- getString(R.string.reader_bookmark_label_page, page, pages)
- } else {
- getString(R.string.reader_bookmark_label_percent, (progress * 100).toInt().coerceIn(0, 100))
- }
- val label = chapter?.let { "$it · $pageLabel" } ?: pageLabel
-
- return BookmarkSnapshot(
- cfi = cfi,
- progress = progress,
- currentPage = page,
- totalPages = pages,
- chapter = chapter,
- label = label
- )
+ private fun selectHighlightColor(color: String?) {
+ pendingHighlightColor = color
+ renderSelectionPalette()
}
- private fun changeFontSize(size: Int) {
- fontSize = size
- app.settingsRepository.setInt(SettingsRepository.KEY_DEFAULT_FONT_SIZE, size)
- binding.fontSizeValueText.text = getString(R.string.reader_font_size_current, fontSize)
- lifecycleScope.launch {
- evalJsSuspend("window.setFontSize($size)")
- }
+ private fun renderSelectionPalette() {
+ binding.colorNoneButton.isSelected = pendingHighlightColor == null
+ binding.colorYellowButton.isSelected = pendingHighlightColor == COLOR_YELLOW
+ binding.colorGreenButton.isSelected = pendingHighlightColor == COLOR_GREEN
+ binding.colorBlueButton.isSelected = pendingHighlightColor == COLOR_BLUE
+ binding.colorPurpleButton.isSelected = pendingHighlightColor == COLOR_PURPLE
+ binding.colorPinkButton.isSelected = pendingHighlightColor == COLOR_PINK
}
- private fun changeFontFamily(family: String) {
- fontFamily = family
- app.settingsRepository.setString(SettingsRepository.KEY_DEFAULT_FONT_FAMILY, family)
- binding.fontFamilyDropdown.setText(family, false)
- lifecycleScope.launch {
- evalJsSuspend("window.setFontFamily('${escapeJs(family)}')")
+ private fun shareSelection() {
+ val selection = currentSelection ?: return
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, selection.text)
}
+ startActivity(Intent.createChooser(intent, "Поделиться цитатой"))
}
- private fun changeTheme(theme: String) {
- readerTheme = theme
- app.settingsRepository.setString(SettingsRepository.KEY_THEME, theme)
- syncThemeButtons()
- lifecycleScope.launch {
- evalJsSuspend("window.setReaderTheme('${escapeJs(theme)}')")
- }
+ private fun copySelection() {
+ val selection = currentSelection ?: return
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("Цитата", selection.text))
+ Toast.makeText(this, "Цитата скопирована", Toast.LENGTH_SHORT).show()
}
- private fun syncThemeButtons() {
- val selectedBackground = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.accent_color))
- val defaultBackground = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.surface_strong))
- val selectedText = ContextCompat.getColor(this, R.color.white)
- val defaultText = ContextCompat.getColor(this, R.color.ink_color)
-
- fun applyState(button: com.google.android.material.button.MaterialButton, isSelected: Boolean) {
- button.isChecked = isSelected
- button.backgroundTintList = if (isSelected) selectedBackground else defaultBackground
- button.setTextColor(if (isSelected) selectedText else defaultText)
- }
-
- applyState(binding.themeWarmButton, readerTheme == "sepia")
- applyState(binding.themeLightButton, readerTheme == "light")
- applyState(binding.themeDarkButton, readerTheme == "dark")
+ private fun openNoteEditor() {
+ val selection = currentSelection ?: return
+ binding.noteQuoteText.text = selection.text
+ binding.noteInput.setText("")
+ binding.noteEditorOverlay.isVisible = true
+ binding.readerSelectionOverlay.isVisible = false
+ binding.noteInput.requestFocus()
+ applySystemUi()
}
- private fun changeBrightness(value: Double) {
- brightness = value.coerceIn(70.0, 120.0)
- app.settingsRepository.setDouble(SettingsRepository.KEY_BRIGHTNESS, brightness)
- binding.brightnessValueMenuText.text = getString(R.string.reader_brightness_value, brightness)
- lifecycleScope.launch {
- evalJsSuspend("window.setBrightness(${brightness.toStringInvariant()})")
- }
+ private fun closeNoteEditor() {
+ binding.noteEditorOverlay.isVisible = false
+ binding.readerSelectionOverlay.isVisible = currentSelection != null
+ applySystemUi()
}
- private fun goToChapter(href: String) {
- if (href.isBlank()) {
- return
- }
- lifecycleScope.launch {
- evalJsSuspend("window.goToChapter('${escapeJs(href)}')")
- }
- hideMenu()
- }
-
- private fun updateCurrentChapter(chapterTitle: String?) {
- currentChapterTitle = chapterTitle.orEmpty().trim()
- allChapters.forEach { chapter ->
- chapter.isCurrent = currentChapterTitle.isNotBlank() && chapter.label == currentChapterTitle
- }
- renderChapterSection()
- }
-
- private fun updateProgressViews() {
- val progressText = if (totalPages == 100) {
- getString(R.string.reader_progress_percent, currentPage)
- } else {
- getString(R.string.reader_progress_pages, currentPage, totalPages)
- }
- val chapterProgressText = if (chapterTotalPages > 1) {
- getString(R.string.reader_chapter_progress, chapterCurrentPage, chapterTotalPages)
- } else {
- getString(R.string.reader_chapter_progress_pending)
- }
-
- val chapterText = currentChapterTitle.ifBlank { getString(R.string.reader_hud_chapter_pending) }
- val hudProgress = if (totalPages == 100) {
- currentPage.coerceIn(0, 100)
- } else {
- ((currentPage.toDouble() / totalPages.coerceAtLeast(1)) * 100)
- .roundToInt()
- .coerceIn(0, 100)
- }
-
- binding.readerProgressText.text = progressText
- binding.progressBadgeText.text = progressText
- binding.chapterProgressText.text = chapterProgressText
- binding.readerHudChapterText.text = chapterText
- binding.readerHudChapterProgressText.text = chapterProgressText
- binding.readerProgressIndicator.progress = hudProgress
- binding.readerHudCard.contentDescription = getString(
- R.string.a11y_reader_status,
- chapterText,
- progressText,
- chapterProgressText
- )
- binding.fontSizeValueText.text = getString(R.string.reader_font_size_current, fontSize)
- binding.brightnessValueMenuText.text = getString(R.string.reader_brightness_value, brightness)
- }
-
- private fun currentChapterSummary(): String {
- if (currentChapterTitle.isNotBlank()) {
- return getString(R.string.reader_chapter_summary_current, currentChapterTitle)
- }
- return if (allChapters.isNotEmpty()) {
- getString(R.string.reader_chapter_summary_count, allChapters.size)
- } else {
- getString(R.string.reader_chapter_results_loading)
- }
- }
-
- private fun currentChapterResultsText(visibleCount: Int): String {
- if (allChapters.isEmpty()) {
- return getString(R.string.reader_chapter_results_loading)
- }
- val query = binding.chapterSearchInput.text?.toString().orEmpty().trim()
- return if (query.isBlank()) {
- getString(R.string.reader_chapter_results_all, visibleCount, allChapters.size)
- } else {
- getString(R.string.reader_chapter_results_filtered, visibleCount, allChapters.size)
- }
- }
-
- private fun maybePersistProgress(
- progress: Double,
- cfi: String?,
- chapter: String?,
- progressCurrentPage: Int,
- progressTotalPages: Int,
- force: Boolean = false
- ) {
+ private fun saveAnnotation(noteText: String, kind: String) {
val book = currentBook ?: return
- if (cfi.isNullOrBlank() && progress <= 0.0) {
- return
- }
-
- currentPage = if (progressCurrentPage > 0) progressCurrentPage else currentPage
- totalPages = if (progressTotalPages > 0) progressTotalPages else totalPages
- updateCurrentChapter(chapter)
- updateProgressViews()
-
- val hasMeaningfulChange =
- abs(progress - lastPersistedProgress) >= 0.005 ||
- (cfi.orEmpty() != lastPersistedCfi) ||
- (chapter.orEmpty() != lastPersistedChapter) ||
- progressCurrentPage != lastPersistedCurrentPage ||
- progressTotalPages != lastPersistedTotalPages
-
- val now = System.currentTimeMillis()
- if (!force) {
- val throttled =
- hasMeaningfulChange && (
- now - lastPersistedAt >= PROGRESS_SAVE_THROTTLE_MS ||
- abs(progress - lastPersistedProgress) >= 0.02 ||
- progressCurrentPage != lastPersistedCurrentPage
- )
- if (!throttled) {
- return
+ val selection = currentSelection ?: return
+ lifecycleScope.launch {
+ quotes = withContext(Dispatchers.IO) {
+ app.bookRepository.addNote(
+ bookId = book.id,
+ cfi = selection.locator,
+ progress = selection.progress,
+ currentPage = selection.currentPage,
+ totalPages = selection.totalPages,
+ chapter = selection.chapter,
+ selectedText = selection.text,
+ noteText = noteText,
+ highlightColor = pendingHighlightColor,
+ kind = kind
+ )
+ app.bookRepository.getNotes(book.id)
}
- } else if (!hasMeaningfulChange) {
- return
+ selection.locator?.takeIf(String::isNotBlank)?.let { locator ->
+ pendingHighlightColor?.let { color -> webController.addHighlight(locator, color) }
+ }
+ binding.noteEditorOverlay.isVisible = false
+ binding.readerSelectionOverlay.isVisible = false
+ currentSelection = null
+ webController.clearSelection()
+ webController.setHighlights(quotes)
+ renderCollections()
+ applySystemUi()
}
+ }
- currentBook = book.copy(
- readingProgress = progress,
- lastCfi = cfi,
- lastChapter = chapter,
- currentPage = progressCurrentPage,
- totalPages = progressTotalPages,
- lastRead = System.currentTimeMillis()
- )
+ private fun deleteQuote(note: ReadingNote) {
+ val bookId = currentBook?.id ?: return
+ lifecycleScope.launch {
+ quotes = withContext(Dispatchers.IO) {
+ app.bookRepository.deleteNote(note)
+ app.bookRepository.getNotes(bookId)
+ }
+ note.cfi?.takeIf(String::isNotBlank)?.let(webController::removeHighlight)
+ webController.setHighlights(quotes)
+ renderCollections()
+ }
+ }
- lifecycleScope.launch(Dispatchers.IO) {
- app.bookRepository.saveProgress(
- bookId = book.id,
- progress = progress,
- cfi = cfi,
- chapter = chapter,
- currentPage = progressCurrentPage,
- totalPages = progressTotalPages
+ private fun closeSelection() {
+ binding.readerSelectionOverlay.isVisible = false
+ currentSelection = null
+ webController.clearSelection()
+ renderChromeVisibility()
+ }
+
+ private fun updatePreferences(transform: ReaderPreferences.() -> ReaderPreferences) {
+ if (finishingReader) return
+ val previousPreferences = preferences
+ val previousOrientation = preferences.orientation
+ val nextPreferences = preferences.transform()
+ if (nextPreferences == previousPreferences) return
+ val enginePreferencesChanged = !nextPreferences.hasSameEnginePreferences(previousPreferences)
+ preferences = nextPreferences
+ if (enginePreferencesChanged) currentBook?.let { book ->
+ paginationCacheKey = paginationCache.key(
+ file = File(book.filePath),
+ preferences = preferences,
+ viewportWidth = resources.displayMetrics.widthPixels,
+ viewportHeight = resources.displayMetrics.heightPixels
)
}
-
- rememberPersistedProgress(progress, cfi, chapter, progressCurrentPage, progressTotalPages)
+ renderSettings()
+ applyNativePreferences()
+ if (bookReady && enginePreferencesChanged) {
+ webController.setPreferences(preferences)
+ }
+ if (preferences.orientation != previousOrientation || orientationJob?.isActive == true) {
+ persistStateThenApplyOrientation()
+ } else {
+ scheduleSettingsSave(immediate = false)
+ }
}
- private fun rememberPersistedProgress(
- progress: Double,
- cfi: String?,
- chapter: String?,
- progressCurrentPage: Int,
- progressTotalPages: Int
- ) {
- lastPersistedProgress = progress
- lastPersistedCfi = cfi.orEmpty()
- lastPersistedChapter = chapter.orEmpty()
- lastPersistedCurrentPage = progressCurrentPage
- lastPersistedTotalPages = progressTotalPages
- lastPersistedAt = System.currentTimeMillis()
+ private fun renderSettings() {
+ updatingSettingsViews = true
+ binding.fontSizeValue.text = preferences.fontSize.toString()
+ binding.fullFontSizeValue.text = preferences.fontSize.toString()
+ binding.fontFamilyDropdown.setText(preferences.fontName, false)
+ binding.fullFontFamilyDropdown.setText(preferences.fontName, false)
+ binding.brightnessSlider.value = preferences.brightness.toFloat()
+ binding.fullBrightnessSlider.value = preferences.brightness.toFloat()
+ binding.brightnessSlider.isEnabled = !preferences.systemBrightness
+ binding.fullBrightnessSlider.isEnabled = !preferences.systemBrightness
+ binding.systemBrightnessSwitch.isChecked = preferences.systemBrightness
+ binding.fullSystemBrightnessSwitch.isChecked = preferences.systemBrightness
+ binding.verticalScrollSwitch.isChecked = preferences.verticalScroll
+ binding.fullVerticalScrollSwitch.isChecked = preferences.verticalScroll
+ binding.orientationDropdown.setText(
+ when (preferences.orientation) {
+ ReaderPreferences.ORIENTATION_PORTRAIT -> "Портретная"
+ ReaderPreferences.ORIENTATION_LANDSCAPE -> "Альбомная"
+ else -> "Автоматически"
+ },
+ false
+ )
+ binding.lineHeightValue.text = String.format(java.util.Locale.getDefault(), "%.1f", preferences.lineHeight)
+ binding.marginValue.text = "${preferences.margin}"
+ binding.volumeButtonsSwitch.isChecked = preferences.volumeButtons
+ binding.invertZonesSwitch.isChecked = preferences.invertZones
+ binding.brightnessGestureSwitch.isChecked = preferences.brightnessGesture
+ binding.keepScreenOnSwitch.isChecked = preferences.keepScreenOn
+ binding.showTitleSwitch.isChecked = preferences.showTitle
+ binding.showStatusSwitch.isChecked = preferences.showStatus
+ binding.themeLightButton.isSelected = preferences.theme == ReaderPreferences.THEME_LIGHT
+ binding.themeSepiaButton.isSelected = preferences.theme == ReaderPreferences.THEME_SEPIA
+ binding.themeDarkButton.isSelected = preferences.theme == ReaderPreferences.THEME_DARK
+ binding.fullThemeLightButton.isSelected = preferences.theme == ReaderPreferences.THEME_LIGHT
+ binding.fullThemeSepiaButton.isSelected = preferences.theme == ReaderPreferences.THEME_SEPIA
+ binding.fullThemeDarkButton.isSelected = preferences.theme == ReaderPreferences.THEME_DARK
+ binding.alignJustifyButton.isSelected = preferences.textAlign == ReaderPreferences.ALIGN_JUSTIFY
+ binding.alignLeftButton.isSelected = preferences.textAlign == ReaderPreferences.ALIGN_LEFT
+ binding.pageTurnTapSwipeButton.isSelected = preferences.pageTurnMode == ReaderPreferences.PAGE_TURN_TAP_SWIPE
+ binding.pageTurnSwipeButton.isSelected = preferences.pageTurnMode == ReaderPreferences.PAGE_TURN_SWIPE
+ binding.pageTurnTapButton.isSelected = preferences.pageTurnMode == ReaderPreferences.PAGE_TURN_TAP
+ updatingSettingsViews = false
}
- private suspend fun evalJsSuspend(script: String): String? =
- suspendCancellableCoroutine { continuation ->
- runOnUiThread {
- binding.readerWebView.evaluateJavascript(script) { result ->
- if (continuation.isActive) {
- continuation.resume(result)
+ private fun applyNativePreferences() {
+ val palette = readerPalette()
+
+ binding.root.setBackgroundColor(palette.canvas)
+ binding.readerWebView.setBackgroundColor(palette.canvas)
+ binding.readerLoadingOverlay.setBackgroundColor(palette.canvas)
+ binding.pageHeader.setBackgroundColor(palette.canvas)
+ binding.readerTopControlsPanel.setBackgroundColor(palette.chrome)
+ binding.readerBottomControlsPanel.setBackgroundColor(palette.chrome)
+ binding.settingsPanel.setBackgroundColor(palette.chrome)
+ binding.selectionPanel.setCardBackgroundColor(palette.chrome)
+ binding.readerContentsOverlay.setBackgroundColor(palette.chrome)
+ binding.contentsTopBar.setBackgroundColor(palette.chrome)
+ binding.contentsBookTitle.setBackgroundColor(palette.chrome)
+ binding.tocRecycler.setBackgroundColor(palette.canvas)
+ binding.bookmarksRecycler.setBackgroundColor(palette.chrome)
+ binding.quotesRecycler.setBackgroundColor(palette.chrome)
+ binding.readerStatusBarBackground.setBackgroundColor(palette.chrome)
+ listOf(
+ binding.readerNavigationBarBottomBackground,
+ binding.readerNavigationBarLeftBackground,
+ binding.readerNavigationBarRightBackground
+ ).forEach { it.setBackgroundColor(palette.navigation) }
+
+ applySettingsPalette(binding.settingsPanel, palette)
+ listOf(
+ binding.fontFamilyDropdown,
+ binding.fullFontFamilyDropdown,
+ binding.orientationDropdown
+ ).forEach { it.setTextColor(palette.settingsSecondary) }
+
+ binding.pageHeaderTitle.setTextColor(palette.bottomText)
+ binding.pageHeaderRemaining.setTextColor(palette.bottomText)
+ binding.readerBottomTitle.setTextColor(palette.bottomText)
+ binding.readerBottomChapter.setTextColor(palette.bottomText)
+ binding.readerPageCount.setTextColor(palette.bottomText)
+ binding.readerChapterRemaining.setTextColor(palette.bottomText)
+ binding.readerLoadingText.setTextColor(palette.bottomText)
+ binding.readerErrorText.setTextColor(palette.bottomText)
+ binding.settingsTitle.setTextColor(palette.settingsText)
+ binding.selectionTitle.setTextColor(palette.settingsText)
+ binding.contentsBookTitle.setTextColor(palette.bottomText)
+ binding.tocEmptyText.setTextColor(palette.bottomText)
+ binding.bookmarksEmptyText.setTextColor(palette.bottomText)
+ binding.quotesEmptyText.setTextColor(palette.bottomText)
+
+ binding.readerBackButton.imageTintList = ColorStateList.valueOf(palette.backIcon)
+ listOf(
+ binding.readerVoiceButton,
+ binding.readerSearchButton,
+ binding.readerSettingsButton,
+ binding.readerContentsButton,
+ binding.readerBookmarkButton
+ ).forEach { it.imageTintList = ColorStateList.valueOf(palette.chromeIcon) }
+ listOf(
+ binding.settingsBackButton,
+ binding.settingsCloseButton,
+ binding.contentsCloseButton,
+ binding.selectionCloseButton
+ ).forEach { it.imageTintList = ColorStateList.valueOf(palette.chromeIcon) }
+ listOf(
+ binding.selectionShareButton,
+ binding.selectionCopyButton,
+ binding.selectionNoteButton
+ ).forEach {
+ it.iconTint = ColorStateList.valueOf(palette.chromeIcon)
+ it.setTextColor(palette.settingsText)
+ }
+
+ binding.themeLightButton.setTextColor(Color.BLACK)
+ binding.fullThemeLightButton.setTextColor(Color.BLACK)
+ binding.themeSepiaButton.setTextColor(Color.parseColor(SEPIA_TEXT))
+ binding.fullThemeSepiaButton.setTextColor(Color.parseColor(SEPIA_TEXT))
+ binding.themeDarkButton.setTextColor(Color.WHITE)
+ binding.fullThemeDarkButton.setTextColor(Color.WHITE)
+ binding.allSettingsButton.backgroundTintList = ColorStateList.valueOf(palette.chrome)
+ binding.allSettingsButton.setTextColor(Color.parseColor(ACCENT_COLOR))
+
+ listOf(binding.brightnessSlider, binding.fullBrightnessSlider).forEach { slider ->
+ slider.thumbTintList = ColorStateList.valueOf(palette.controlIcon)
+ slider.trackActiveTintList = ColorStateList.valueOf(palette.controlIcon)
+ slider.trackInactiveTintList = ColorStateList.valueOf(palette.settingsSecondary)
+ }
+ binding.readerProgressSlider.thumbTintList = ColorStateList.valueOf(palette.bottomText)
+ binding.readerProgressSlider.trackActiveTintList = ColorStateList.valueOf(palette.bottomText)
+ binding.readerProgressSlider.trackInactiveTintList = ColorStateList.valueOf(palette.bottomText)
+
+ if (preferences.systemBrightness) {
+ window.attributes = window.attributes.apply {
+ screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+ }
+ } else {
+ window.attributes = window.attributes.apply {
+ screenBrightness = (preferences.brightness / 100f).coerceIn(0.1f, 1f)
+ }
+ }
+ if (preferences.keepScreenOn) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ showContentsTab(contentsTab)
+ syncBookmarkButton()
+ renderChromeVisibility()
+ }
+
+ private fun applySettingsPalette(view: View, palette: ReaderPalette) {
+ val paletteTag = view.tag as? String
+ when (paletteTag) {
+ PALETTE_TAG_ROW -> {
+ if (view is MaterialButton) {
+ view.backgroundTintList = ColorStateList.valueOf(palette.chrome)
+ } else {
+ view.background = rowBackground(palette.chrome, palette.separator)
+ }
+ }
+ PALETTE_TAG_SEPARATOR -> view.setBackgroundColor(palette.separator)
+ PALETTE_TAG_CONTROL -> view.background = roundedBackground(palette.control, 4f)
+ PALETTE_TAG_OPTION -> view.background = selectableOptionBackground(palette)
+ PALETTE_TAG_MODE -> view.background = selectableModeBackground(palette)
+ }
+
+ when (view) {
+ is MaterialButton -> if (paletteTag == PALETTE_TAG_MODE) {
+ view.setTextColor(palette.settingsText)
+ }
+ is TextView -> view.setTextColor(palette.settingsText)
+ }
+ if (view is ImageView && (paletteTag == PALETTE_TAG_CONTROL || paletteTag == PALETTE_TAG_OPTION)) {
+ view.imageTintList = ColorStateList.valueOf(palette.controlIcon)
+ }
+ if (view is ViewGroup) view.children.forEach { child -> applySettingsPalette(child, palette) }
+ }
+
+ private fun rowBackground(background: Int, separator: Int): LayerDrawable {
+ return LayerDrawable(arrayOf(ColorDrawable(background), ColorDrawable(separator))).apply {
+ setLayerGravity(1, Gravity.BOTTOM)
+ setLayerHeight(1, dp(1f))
+ }
+ }
+
+ private fun roundedBackground(color: Int, radiusDp: Float, strokeColor: Int? = null): GradientDrawable {
+ return GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(color)
+ cornerRadius = resources.displayMetrics.density * radiusDp
+ strokeColor?.let { setStroke(dp(2f), it) }
+ }
+ }
+
+ private fun selectableOptionBackground(palette: ReaderPalette): StateListDrawable {
+ return StateListDrawable().apply {
+ addState(
+ intArrayOf(android.R.attr.state_selected),
+ roundedBackground(palette.control, 2f, Color.parseColor(ACCENT_COLOR))
+ )
+ addState(IntArray(0), roundedBackground(palette.control, 4f))
+ }
+ }
+
+ private fun selectableModeBackground(palette: ReaderPalette): StateListDrawable {
+ return StateListDrawable().apply {
+ addState(
+ intArrayOf(android.R.attr.state_selected),
+ roundedBackground(palette.chrome, 0f, Color.parseColor(ACCENT_COLOR))
+ )
+ addState(IntArray(0), roundedBackground(palette.chrome, 0f))
+ }
+ }
+
+ private fun dp(value: Float): Int = (resources.displayMetrics.density * value).roundToInt().coerceAtLeast(1)
+
+ private fun readerPalette(): ReaderPalette = when (preferences.theme) {
+ ReaderPreferences.THEME_SEPIA -> ReaderPalette(
+ canvas = Color.parseColor(SEPIA_CANVAS),
+ chrome = Color.parseColor(SEPIA_CHROME),
+ settingsText = Color.parseColor(SEPIA_TEXT),
+ settingsSecondary = Color.parseColor(SEPIA_SECONDARY),
+ chromeIcon = Color.parseColor(SEPIA_CHROME_ICON),
+ backIcon = Color.parseColor(SEPIA_CHROME_ICON),
+ bottomText = Color.parseColor(SEPIA_BOTTOM_TEXT),
+ separator = Color.parseColor(SEPIA_SEPARATOR),
+ control = Color.parseColor(SEPIA_CONTROL),
+ controlIcon = Color.parseColor(SEPIA_CONTROL_ICON),
+ settingsActive = Color.parseColor(SEPIA_SETTINGS_ACTIVE),
+ navigation = Color.parseColor(LIGHT_NAVIGATION)
+ )
+ ReaderPreferences.THEME_DARK -> ReaderPalette(
+ canvas = Color.parseColor(DARK_CANVAS),
+ chrome = Color.parseColor(DARK_CHROME),
+ settingsText = Color.parseColor(DARK_SETTINGS_TEXT),
+ settingsSecondary = Color.parseColor(DARK_SETTINGS_SECONDARY),
+ chromeIcon = Color.parseColor(DARK_CHROME_ICON),
+ backIcon = Color.parseColor(DARK_CHROME_ICON),
+ bottomText = Color.parseColor(DARK_BOTTOM_TEXT),
+ separator = Color.parseColor(DARK_SEPARATOR),
+ control = Color.parseColor(DARK_CONTROL),
+ controlIcon = Color.parseColor(DARK_CONTROL_ICON),
+ settingsActive = Color.parseColor(DARK_SETTINGS_ACTIVE),
+ navigation = Color.parseColor(DARK_NAVIGATION)
+ )
+ else -> ReaderPalette(
+ canvas = Color.WHITE,
+ chrome = Color.parseColor(CHROME_COLOR),
+ settingsText = Color.BLACK,
+ settingsSecondary = Color.parseColor(LIGHT_SETTINGS_SECONDARY),
+ chromeIcon = Color.parseColor(PRIMARY_GRAY),
+ backIcon = Color.parseColor(SECONDARY_GRAY),
+ bottomText = Color.parseColor(PRIMARY_GRAY),
+ separator = Color.parseColor(LIGHT_SEPARATOR),
+ control = Color.parseColor(LIGHT_CONTROL),
+ controlIcon = Color.parseColor(LIGHT_CONTROL_ICON),
+ settingsActive = Color.parseColor(LIGHT_SETTINGS_ACTIVE),
+ navigation = Color.parseColor(LIGHT_NAVIGATION)
+ )
+ }
+
+ private fun applyRequestedOrientation(snapshot: ReaderPreferences = preferences) {
+ val orientation = when (snapshot.orientation) {
+ ReaderPreferences.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
+ ReaderPreferences.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ }
+ if (requestedOrientation != orientation) requestedOrientation = orientation
+ }
+
+ private fun applySystemUi() {
+ val controller = WindowCompat.getInsetsController(window, binding.root)
+ controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ val palette = readerPalette()
+ val modalVisible = binding.readerControlsOverlay.isVisible ||
+ binding.readerSettingsOverlay.isVisible ||
+ binding.readerContentsOverlay.isVisible ||
+ binding.readerSelectionOverlay.isVisible ||
+ binding.noteEditorOverlay.isVisible
+ binding.readerSystemBarBackgrounds.isVisible = modalVisible
+ val contentsVisible = binding.readerContentsOverlay.isVisible
+ val statusBackground = if (contentsVisible) Color.WHITE else palette.chrome
+ val navigationBackground = if (contentsVisible) Color.WHITE else palette.navigation
+ binding.readerStatusBarBackground.setBackgroundColor(statusBackground)
+ listOf(
+ binding.readerNavigationBarBottomBackground,
+ binding.readerNavigationBarLeftBackground,
+ binding.readerNavigationBarRightBackground
+ ).forEach { it.setBackgroundColor(navigationBackground) }
+ controller.isAppearanceLightStatusBars = modalVisible || preferences.theme != ReaderPreferences.THEME_DARK
+ controller.isAppearanceLightNavigationBars = true
+ binding.readerSettingsButton.imageTintList = ColorStateList.valueOf(
+ if (binding.readerSettingsOverlay.isVisible) palette.settingsActive else palette.chromeIcon
+ )
+ when {
+ modalVisible -> controller.show(WindowInsetsCompat.Type.systemBars())
+ preferences.showStatus -> {
+ controller.show(WindowInsetsCompat.Type.statusBars())
+ controller.hide(WindowInsetsCompat.Type.navigationBars())
+ }
+ else -> controller.hide(WindowInsetsCompat.Type.systemBars())
+ }
+ }
+
+ private fun scheduleProgressSave(immediate: Boolean) {
+ val book = currentBook ?: return
+ if (!bookReady) return
+ progressSaveJob?.cancel()
+ val snapshot = position
+ progressSaveJob = app.applicationScope.launch {
+ try {
+ if (!immediate) delay(PROGRESS_SAVE_DEBOUNCE_MS)
+ persistPosition(snapshot, book)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Log.e(TAG, "Не удалось сохранить позицию чтения", error)
+ }
+ }
+ }
+
+ private fun scheduleSettingsSave(immediate: Boolean) {
+ settingsSaveJob?.cancel()
+ val snapshot = preferences
+ settingsSaveJob = app.applicationScope.launch {
+ try {
+ if (!immediate) delay(SETTINGS_SAVE_DEBOUNCE_MS)
+ persistPreferences(snapshot)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Log.e(TAG, "Не удалось сохранить настройки читалки", error)
+ }
+ }
+ }
+
+ private suspend fun persistPreferences(snapshot: ReaderPreferences) {
+ withContext(Dispatchers.IO) {
+ app.readerPersistenceMutex.withLock {
+ snapshot.persist(app.settingsRepository)
+ }
+ }
+ }
+
+ private suspend fun persistPosition(snapshot: ReaderPosition, book: Book) {
+ withContext(Dispatchers.IO) {
+ app.readerPersistenceMutex.withLock {
+ val effectiveSnapshot = app.readerStateCoordinator.latest(book.id)?.toReaderPosition()
+ ?: snapshot
+ val signature = listOf(
+ book.id,
+ effectiveSnapshot.progress,
+ effectiveSnapshot.locator,
+ effectiveSnapshot.chapter,
+ effectiveSnapshot.currentPage,
+ effectiveSnapshot.totalPages
+ ).joinToString("|")
+ if (signature == app.readerStateCoordinator.lastPersistedSignature(book.id)) {
+ return@withLock
+ }
+ app.bookRepository.saveProgress(
+ bookId = book.id,
+ progress = effectiveSnapshot.progress,
+ cfi = effectiveSnapshot.locator,
+ chapter = effectiveSnapshot.chapter,
+ currentPage = effectiveSnapshot.currentPage,
+ totalPages = effectiveSnapshot.totalPages
+ )
+ app.readerStateCoordinator.markPersisted(book.id, signature)
+ }
+ }
+ }
+
+ private fun persistStateThenApplyOrientation() {
+ val oldSettingsJob = settingsSaveJob
+ val oldProgressJob = progressSaveJob
+ val oldOrientationJob = orientationJob
+ val preferenceSnapshot = preferences
+ val positionSnapshot = position
+ val bookSnapshot = currentBook
+ orientationJob = lifecycleScope.launch {
+ var applyOrientation = true
+ try {
+ oldOrientationJob?.cancelAndJoin()
+ oldSettingsJob?.cancelAndJoin()
+ oldProgressJob?.cancelAndJoin()
+ persistPreferences(preferenceSnapshot)
+ if (bookSnapshot != null) {
+ persistPosition(positionSnapshot, bookSnapshot)
+ }
+ } catch (error: CancellationException) {
+ applyOrientation = false
+ throw error
+ } catch (error: Exception) {
+ Log.e(TAG, "Не удалось сохранить состояние перед поворотом", error)
+ Toast.makeText(
+ this@ReaderActivity,
+ "Настройки применены, но сохранить их не удалось",
+ Toast.LENGTH_SHORT
+ ).show()
+ } finally {
+ if (applyOrientation && preferences.orientation == preferenceSnapshot.orientation) {
+ applyRequestedOrientation(preferenceSnapshot)
+ }
+ }
+ }
+ }
+
+ private fun retryReader() {
+ val book = currentBook ?: return
+ if (retryingReader) return
+ retryingReader = true
+ val pendingOrientationJob = orientationJob?.takeIf { it.isActive }
+ pendingOrientationJob?.cancel()
+ showLoading("Повторяю загрузку…")
+ lifecycleScope.launch {
+ try {
+ pendingOrientationJob?.cancelAndJoin()
+ progressSaveJob?.cancelAndJoin()
+ settingsSaveJob?.cancelAndJoin()
+ try {
+ persistPreferences(preferences)
+ persistPosition(position, book)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Log.e(TAG, "Не удалось сохранить состояние перед повторной загрузкой", error)
+ Toast.makeText(
+ this@ReaderActivity,
+ "Не удалось сохранить текущую позицию",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ webController.updatePendingPosition(position.locator, position.progress, preferences)
+ bookReady = false
+ val previousRequestedOrientation = requestedOrientation
+ applyRequestedOrientation(preferences)
+ if (requestedOrientation == previousRequestedOrientation && !webController.reload()) recreate()
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ Log.e(TAG, "Повторная загрузка читалки завершилась ошибкой", error)
+ showError("Не удалось повторно открыть книгу", retryAllowed = true)
+ } finally {
+ retryingReader = false
+ }
+ }
+ }
+
+ private fun finishReader() {
+ if (finishingReader) return
+ finishingReader = true
+ bookReady = false
+ val bookSnapshot = currentBook
+ val positionSnapshot = position
+ val preferenceSnapshot = preferences
+ val progressJobSnapshot = progressSaveJob
+ val settingsJobSnapshot = settingsSaveJob
+ val orientationJobSnapshot = orientationJob
+ orientationJobSnapshot?.cancel()
+ finishJob = app.applicationScope.launch {
+ var saveError: Exception? = null
+ try {
+ orientationJobSnapshot?.cancelAndJoin()
+ progressJobSnapshot?.cancelAndJoin()
+ settingsJobSnapshot?.cancelAndJoin()
+ persistPreferences(preferenceSnapshot)
+ if (bookSnapshot != null) persistPosition(positionSnapshot, bookSnapshot)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ saveError = error
+ Log.e(TAG, "Не удалось сохранить состояние при закрытии читалки", error)
+ } finally {
+ withContext(NonCancellable + Dispatchers.Main.immediate) {
+ if (saveError != null && !isDestroyed) {
+ Toast.makeText(
+ this@ReaderActivity,
+ "Не удалось сохранить текущую позицию",
+ Toast.LENGTH_SHORT
+ ).show()
}
- }
- }
- }
-
- private fun escapeJs(value: String): String =
- value
- .replace("\\", "\\\\")
- .replace("'", "\\'")
- .replace("\"", "\\\"")
- .replace("\n", "\\n")
- .replace("\r", "\\r")
- .replace("\t", "\\t")
-
- private fun unescapeJsResult(value: String): String {
- var result = value
- if (result.startsWith("\"") && result.endsWith("\"")) {
- result = result.substring(1, result.length - 1)
- }
- return result
- .replace("\\\"", "\"")
- .replace("\\\\", "\\")
- .replace("\\/", "/")
- .replace("\\n", "\n")
- .replace("\\r", "\r")
- .replace("\\t", "\t")
- }
-
- private fun Double.toStringInvariant(): String = java.lang.String.format(Locale.US, "%s", this)
-
- inner class ReaderBridge {
- @JavascriptInterface
- fun postMessage(message: String) {
- runOnUiThread {
- handleBridgeMessage(message)
- }
- }
- }
-
- private fun handleBridgeMessage(message: String) {
- val json = runCatching { JSONObject(message) }.getOrNull() ?: return
- val action = json.optString("action")
- val data = json.optJSONObject("data") ?: JSONObject()
-
- when (action) {
- "readerReady" -> {
- isReaderReady = true
- sendSafeAreaInsetsToReader()
- maybeLoadBookIntoWebView()
- }
-
- "toggleMenu" -> toggleMenu()
-
- "readerNavigation" -> showReaderHudTemporarily()
-
- "progressUpdate" -> {
- val progress = data.optDouble("progress", 0.0)
- val cfi = data.optString("cfi").takeIf { it.isNotBlank() }
- val chapter = data.optString("chapter").takeIf { it.isNotBlank() }
- currentPage = data.optInt("currentPage", currentPage)
- totalPages = data.optInt("totalPages", totalPages)
- chapterCurrentPage = data.optInt("chapterCurrentPage", chapterCurrentPage)
- chapterTotalPages = data.optInt("chapterTotalPages", chapterTotalPages)
- updateCurrentChapter(chapter)
- updateProgressViews()
- showInitialReaderHudIfNeeded()
- maybePersistProgress(progress, cfi, chapter, currentPage, totalPages, force = false)
- }
-
- "chaptersLoaded" -> {
- val chapters = data.optJSONArray("chapters") ?: JSONArray()
- allChapters.clear()
- for (index in 0 until chapters.length()) {
- val chapter = chapters.optJSONObject(index) ?: continue
- allChapters += ReaderChapterItem(
- label = chapter.optString("label"),
- href = chapter.optString("href"),
- index = index
- )
- }
- updateCurrentChapter(currentChapterTitle)
- renderChapterSection()
- }
-
- "bookReady" -> {
- applyReaderPreferences()
- renderBookSearchSection()
- showInitialReaderHudIfNeeded()
- }
-
- "bookSearchResults" -> handleBookSearchResults(data)
-
- "saveLocations" -> {
- val locations = data.optString("locations")
- val book = currentBook ?: return
- currentBook = book.copy(locations = locations)
- lifecycleScope.launch(Dispatchers.IO) {
- app.bookRepository.saveLocations(book.id, locations)
+ if (!isDestroyed) finish()
}
}
}
}
- companion object {
- const val EXTRA_BOOK_ID = "book_id"
-
- private const val BASE64_RAW_CHUNK_SIZE = 48 * 1024
- private const val PROGRESS_SAVE_THROTTLE_MS = 2_000L
- private const val READER_HUD_CONTENT_INSET_DP = 80
- private const val READER_HUD_AUTO_HIDE_DELAY_MS = 3_500L
- private const val READER_HUD_FADE_MS = 180L
- private const val READER_EDGE_TAP_FRACTION = 0.3f
- private const val READER_ASSET_URL = "https://appassets.androidplatform.net/assets/wwwroot/index.html"
+ private fun handleBack() {
+ when {
+ binding.noteEditorOverlay.isVisible -> closeNoteEditor()
+ binding.readerSelectionOverlay.isVisible -> closeSelection()
+ binding.readerContentsOverlay.isVisible -> closeContents()
+ binding.readerSettingsOverlay.isVisible -> closeSettings()
+ binding.readerControlsOverlay.isVisible -> {
+ binding.readerControlsOverlay.isVisible = false
+ renderChromeVisibility()
+ }
+ else -> finishReader()
+ }
}
- private data class EdgeInsets(
+ private fun openExternalLink(url: String) {
+ val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return
+ val intent = when (uri.scheme?.lowercase()) {
+ "http", "https" -> Intent(Intent.ACTION_VIEW, uri)
+ "mailto" -> Intent(Intent.ACTION_SENDTO, uri)
+ else -> return
+ }
+ runCatching { startActivity(intent) }
+ .onFailure { Toast.makeText(this, "Не удалось открыть ссылку", Toast.LENGTH_SHORT).show() }
+ }
+
+ private fun showLoading(message: String) {
+ binding.readerLoadingOverlay.isVisible = true
+ binding.readerLoadingText.isVisible = true
+ binding.readerLoadingText.text = message
+ binding.readerErrorText.isVisible = false
+ binding.readerRetryButton.isVisible = false
+ }
+
+ private fun hideLoading() {
+ binding.readerLoadingOverlay.isVisible = false
+ }
+
+ private fun showError(message: String, retryAllowed: Boolean = true) {
+ binding.readerLoadingOverlay.isVisible = true
+ binding.readerLoadingText.isVisible = false
+ binding.readerErrorText.isVisible = true
+ binding.readerErrorText.text = message
+ binding.readerRetryButton.isVisible = retryAllowed
+ }
+
+ private fun View.paddingInsets() = Insets(paddingLeft, paddingTop, paddingRight, paddingBottom)
+
+ private fun Bundle.restoreReaderPosition(): ReaderPosition? {
+ val requestedBookId = intent.getLongExtra(EXTRA_BOOK_ID, 0L)
+ if (getLong(STATE_BOOK_ID, 0L) != requestedBookId || !containsKey(STATE_PROGRESS)) return null
+ val restoredProgress = getDouble(STATE_PROGRESS, 0.0)
+ return ReaderPosition(
+ progress = if (restoredProgress.isFinite()) restoredProgress.coerceIn(0.0, 1.0) else 0.0,
+ locator = getString(STATE_LOCATOR),
+ chapter = getString(STATE_CHAPTER),
+ currentPage = getInt(STATE_CURRENT_PAGE, 1).coerceAtLeast(1),
+ totalPages = getInt(STATE_TOTAL_PAGES, 1).coerceAtLeast(1),
+ chapterCurrentPage = getInt(STATE_CHAPTER_CURRENT_PAGE, 1).coerceAtLeast(1),
+ chapterTotalPages = getInt(STATE_CHAPTER_TOTAL_PAGES, 1).coerceAtLeast(1)
+ )
+ }
+
+ private fun publishPosition() {
+ val bookId = currentBook?.id ?: return
+ app.readerStateCoordinator.publish(
+ bookId = bookId,
+ progress = position.progress,
+ locator = position.locator,
+ chapter = position.chapter,
+ currentPage = position.currentPage,
+ totalPages = position.totalPages,
+ chapterCurrentPage = position.chapterCurrentPage,
+ chapterTotalPages = position.chapterTotalPages
+ )
+ }
+
+ private fun ReaderSessionPosition.toReaderPosition() = ReaderPosition(
+ progress = progress,
+ locator = locator,
+ chapter = chapter,
+ currentPage = currentPage,
+ totalPages = totalPages,
+ chapterCurrentPage = chapterCurrentPage,
+ chapterTotalPages = chapterTotalPages
+ )
+
+ private fun View.applyInsets(base: Insets, left: Int, top: Int, right: Int, bottom: Int) {
+ updatePadding(
+ left = base.left + left,
+ top = base.top + top,
+ right = base.right + right,
+ bottom = base.bottom + bottom
+ )
+ }
+
+ private fun updateReaderViewportMargins() {
+ val headerHeight = if (preferences.showTitle) dp(25) else 0
+ val contentTop = safeInsets.top + headerHeight
+ (binding.pageHeader.layoutParams as? android.widget.FrameLayout.LayoutParams)?.let { params ->
+ if (params.topMargin != safeInsets.top) {
+ params.topMargin = safeInsets.top
+ binding.pageHeader.layoutParams = params
+ }
+ }
+ (binding.readerWebView.layoutParams as? android.widget.FrameLayout.LayoutParams)?.let { params ->
+ if (
+ params.topMargin != contentTop ||
+ params.leftMargin != safeInsets.left ||
+ params.rightMargin != safeInsets.right
+ ) {
+ params.topMargin = contentTop
+ params.leftMargin = safeInsets.left
+ params.rightMargin = safeInsets.right
+ binding.readerWebView.layoutParams = params
+ }
+ }
+ (binding.readerLoadingOverlay.layoutParams as? android.widget.FrameLayout.LayoutParams)?.let { params ->
+ if (
+ params.topMargin != contentTop ||
+ params.leftMargin != safeInsets.left ||
+ params.rightMargin != safeInsets.right
+ ) {
+ params.topMargin = contentTop
+ params.leftMargin = safeInsets.left
+ params.rightMargin = safeInsets.right
+ binding.readerLoadingOverlay.layoutParams = params
+ }
+ }
+ (binding.readerPersistentProgress.layoutParams as? android.widget.FrameLayout.LayoutParams)?.let { params ->
+ if (params.leftMargin != safeInsets.left || params.rightMargin != safeInsets.right) {
+ params.leftMargin = safeInsets.left
+ params.rightMargin = safeInsets.right
+ binding.readerPersistentProgress.layoutParams = params
+ }
+ }
+ }
+
+ private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
+
+ private fun com.google.android.material.materialswitch.MaterialSwitch.setPreferenceListener(
+ transform: ReaderPreferences.(Boolean) -> ReaderPreferences
+ ) {
+ setOnCheckedChangeListener { _, checked ->
+ if (!updatingSettingsViews) updatePreferences { transform(checked) }
+ }
+ }
+
+ private data class ReaderPosition(
+ val progress: Double = 0.0,
+ val locator: String? = null,
+ val chapter: String? = null,
+ val currentPage: Int = 1,
+ val totalPages: Int = 1,
+ val chapterCurrentPage: Int = 1,
+ val chapterTotalPages: Int = 1
+ )
+
+ private data class Insets(
val left: Int = 0,
val top: Int = 0,
val right: Int = 0,
val bottom: Int = 0
)
- private data class BookmarkSnapshot(
- val cfi: String?,
- val progress: Double,
- val currentPage: Int,
- val totalPages: Int,
- val chapter: String?,
- val label: String
+ private data class ReaderPalette(
+ val canvas: Int,
+ val chrome: Int,
+ val settingsText: Int,
+ val settingsSecondary: Int,
+ val chromeIcon: Int,
+ val backIcon: Int,
+ val bottomText: Int,
+ val separator: Int,
+ val control: Int,
+ val controlIcon: Int,
+ val settingsActive: Int,
+ val navigation: Int
)
- private data class NoteSnapshot(
- val cfi: String?,
- val progress: Double,
- val currentPage: Int,
- val totalPages: Int,
- val chapter: String?,
- val selectedText: String?
- )
+ private enum class ContentsTab { TOC, BOOKMARKS, QUOTES }
- private fun dp(value: Int): Int =
- (value * resources.displayMetrics.density).roundToInt()
-
- private fun EdgeInsets.toCssInsets(): EdgeInsets {
- val density = resources.displayMetrics.density.takeIf { it > 0f } ?: 1f
- return EdgeInsets(
- left = (left / density).roundToInt(),
- top = (top / density).roundToInt(),
- right = (right / density).roundToInt(),
- bottom = (bottom / density).roundToInt()
+ companion object {
+ const val EXTRA_BOOK_ID = "book_id"
+ private const val TAG = "ReaderActivity"
+ private const val STATE_PREFERENCES = "reader.preferences"
+ private const val STATE_FINISHING = "reader.finishing"
+ private const val STATE_BOOK_ID = "reader.book_id"
+ private const val STATE_PROGRESS = "reader.progress"
+ private const val STATE_LOCATOR = "reader.locator"
+ private const val STATE_CHAPTER = "reader.chapter"
+ private const val STATE_CURRENT_PAGE = "reader.current_page"
+ private const val STATE_TOTAL_PAGES = "reader.total_pages"
+ private const val STATE_CHAPTER_CURRENT_PAGE = "reader.chapter_current_page"
+ private const val STATE_CHAPTER_TOTAL_PAGES = "reader.chapter_total_pages"
+ private const val PROGRESS_SAVE_DEBOUNCE_MS = 1_500L
+ private const val SETTINGS_SAVE_DEBOUNCE_MS = 180L
+ private const val NATIVE_TAP_MAX_DURATION_MS = 350L
+ private const val NATIVE_TAP_DUPLICATE_WINDOW_MS = 450L
+ private const val READER_CENTER_ZONE_START = 0.28f
+ private const val READER_CENTER_ZONE_END = 0.72f
+ private val FATAL_ERROR_STAGES = setOf(
+ "book.load",
+ "api.loadBook",
+ "shell.load",
+ "renderer"
)
+ private const val BOOKMARK_PROGRESS_TOLERANCE = 0.0015
+ private const val CHROME_COLOR = "#F4F4F5"
+ private const val ACCENT_COLOR = "#EC622B"
+ private const val PRIMARY_GRAY = "#626262"
+ private const val SECONDARY_GRAY = "#9D9C9F"
+ private const val SECONDARY_ON_LIGHT = "#767579"
+ private const val LIGHT_SETTINGS_SECONDARY = "#767579"
+ private const val LIGHT_SEPARATOR = "#DDDDDD"
+ private const val LIGHT_CONTROL = "#DCDCDC"
+ private const val LIGHT_CONTROL_ICON = "#666668"
+ private const val LIGHT_SETTINGS_ACTIVE = "#EC9C6E"
+ private const val LIGHT_NAVIGATION = "#FEFEFE"
+ private const val SEPIA_CANVAS = "#F6F3E0"
+ private const val SEPIA_CHROME = "#F0E8DA"
+ private const val SEPIA_TEXT = "#503922"
+ private const val SEPIA_SECONDARY = "#776A57"
+ private const val SEPIA_CHROME_ICON = "#92826E"
+ private const val SEPIA_BOTTOM_TEXT = "#8F806B"
+ private const val SEPIA_SEPARATOR = "#E7DFD0"
+ private const val SEPIA_CONTROL = "#DFD5C7"
+ private const val SEPIA_CONTROL_ICON = "#776A57"
+ private const val SEPIA_SETTINGS_ACTIVE = "#EB9764"
+ private const val DARK_CANVAS = "#000000"
+ private const val DARK_CHROME = "#1A1A1A"
+ private const val DARK_SETTINGS_TEXT = "#9D9C9F"
+ private const val DARK_SETTINGS_SECONDARY = "#828183"
+ private const val DARK_CHROME_ICON = "#676668"
+ private const val DARK_BOTTOM_TEXT = "#666666"
+ private const val DARK_SEPARATOR = "#242325"
+ private const val DARK_CONTROL = "#262626"
+ private const val DARK_CONTROL_ICON = "#A0A0A0"
+ private const val DARK_SETTINGS_ACTIVE = "#94431D"
+ private const val DARK_NAVIGATION = "#E9E9E9"
+ private const val PALETTE_TAG_ROW = "reader_v2_row"
+ private const val PALETTE_TAG_SEPARATOR = "reader_v2_separator"
+ private const val PALETTE_TAG_CONTROL = "reader_v2_control"
+ private const val PALETTE_TAG_OPTION = "reader_v2_option"
+ private const val PALETTE_TAG_MODE = "reader_v2_mode"
+ private const val COLOR_YELLOW = "#F8D8A1"
+ private const val COLOR_GREEN = "#AAD2A4"
+ private const val COLOR_BLUE = "#A4CCFB"
+ private const val COLOR_PURPLE = "#C99BF9"
+ private const val COLOR_PINK = "#EC9EAC"
}
}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderEvent.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderEvent.kt
new file mode 100644
index 0000000..6be586b
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderEvent.kt
@@ -0,0 +1,139 @@
+package com.aletheia.app.ui.reader
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+sealed interface ReaderEvent {
+ data object ShellReady : ReaderEvent
+ data object ToggleControls : ReaderEvent
+ data object Navigation : ReaderEvent
+ data class BookReady(val totalPages: Int) : ReaderEvent
+ data class Progress(
+ val progress: Double,
+ val locator: String?,
+ val chapter: String?,
+ val currentPage: Int,
+ val totalPages: Int,
+ val chapterCurrentPage: Int,
+ val chapterTotalPages: Int
+ ) : ReaderEvent
+
+ data class Toc(val chapters: List) : ReaderEvent
+ data class Selection(val snapshot: ReaderSelection?) : ReaderEvent
+ data class Search(val total: Int, val currentIndex: Int, val query: String) : ReaderEvent
+ data class ExternalLink(val url: String) : ReaderEvent
+ data class BrightnessDelta(val delta: Double) : ReaderEvent
+ data class PaginationCache(val locations: String) : ReaderEvent
+ data class Error(
+ val stage: String?,
+ val code: String?,
+ val message: String,
+ val recoverable: Boolean = true
+ ) : ReaderEvent
+
+ companion object {
+ fun parse(message: String): ReaderEvent? {
+ val root = runCatching { JSONObject(message) }.getOrNull() ?: return null
+ if (root.optInt("version", 2) != 2) return null
+ val payload = root.optJSONObject("payload") ?: JSONObject()
+ return when (root.optString("type")) {
+ "shellReady" -> ShellReady
+ "toggleControls" -> ToggleControls
+ "navigation" -> Navigation
+ "bookReady" -> BookReady(payload.optInt("totalPages", 1).coerceAtLeast(1))
+ "progress" -> Progress(
+ progress = payload.optDouble("progress", 0.0).coerceIn(0.0, 1.0),
+ locator = payload.locatorString(),
+ chapter = payload.optString("chapter").takeIf(String::isNotBlank),
+ currentPage = payload.optInt("currentPage", 1).coerceAtLeast(1),
+ totalPages = payload.optInt("totalPages", 1).coerceAtLeast(1),
+ chapterCurrentPage = payload.optPositiveInt("chapterCurrentPage", "chapterPage"),
+ chapterTotalPages = payload.optPositiveInt("chapterTotalPages", "chapterTotal")
+ )
+ "toc" -> Toc(
+ parseToc(
+ payload.optJSONArray("items")
+ ?: payload.optJSONArray("chapters")
+ ?: JSONArray()
+ )
+ )
+ "selection" -> {
+ val text = payload.optString("text").trim()
+ Selection(
+ if (text.isBlank()) null else ReaderSelection(
+ text = text,
+ locator = payload.locatorString(),
+ progress = payload.optDouble("progress", 0.0).coerceIn(0.0, 1.0),
+ currentPage = payload.optInt("currentPage", 1).coerceAtLeast(1),
+ totalPages = payload.optInt("totalPages", 1).coerceAtLeast(1),
+ chapter = payload.optString("chapter").takeIf(String::isNotBlank)
+ )
+ )
+ }
+ "search" -> Search(
+ total = payload.optInt("total", 0).coerceAtLeast(0),
+ currentIndex = payload.optInt("currentIndex", -1),
+ query = payload.optString("query")
+ )
+ "externalLink" -> payload.optString("url").takeIf(String::isNotBlank)?.let(::ExternalLink)
+ "brightnessDelta" -> BrightnessDelta(payload.optDouble("delta", 0.0))
+ "paginationCache" -> payload.optString("locations")
+ .takeIf(String::isNotBlank)
+ ?.let(::PaginationCache)
+ "error" -> Error(
+ stage = payload.optString("stage").takeIf(String::isNotBlank),
+ code = payload.optString("code").takeIf(String::isNotBlank),
+ message = payload.optString("message", "Не удалось открыть книгу"),
+ recoverable = payload.optBoolean("recoverable", true)
+ )
+ else -> null
+ }
+ }
+
+ private fun parseToc(array: JSONArray): List = buildList {
+ for (index in 0 until array.length()) {
+ val item = array.optJSONObject(index) ?: continue
+ val href = item.optString("href")
+ val label = item.optString("label").trim()
+ if (href.isNotBlank() && label.isNotBlank()) {
+ add(
+ ReaderTocEntry(
+ label = label,
+ href = href,
+ depth = item.optInt("depth", 0).coerceAtLeast(0),
+ page = item.optInt("page", 0).coerceAtLeast(0)
+ )
+ )
+ }
+ }
+ }
+
+ private fun JSONObject.locatorString(): String? = when (val locator = opt("locator")) {
+ is JSONObject -> locator.toString()
+ is String -> locator.takeIf(String::isNotBlank)
+ else -> null
+ }
+
+ private fun JSONObject.optPositiveInt(primaryName: String, fallbackName: String): Int {
+ val primary = optInt(primaryName, 0)
+ val value = if (primary > 0) primary else optInt(fallbackName, 1)
+ return value.coerceAtLeast(1)
+ }
+ }
+}
+
+data class ReaderTocEntry(
+ val label: String,
+ val href: String,
+ val depth: Int = 0,
+ val page: Int = 0
+)
+
+data class ReaderSelection(
+ val text: String,
+ val locator: String?,
+ val progress: Double,
+ val currentPage: Int,
+ val totalPages: Int,
+ val chapter: String?
+)
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderHubFragment.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderHubFragment.kt
new file mode 100644
index 0000000..89366aa
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderHubFragment.kt
@@ -0,0 +1,77 @@
+package com.aletheia.app.ui.reader
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.aletheia.app.AletheiaApplication
+import com.aletheia.app.R
+import com.aletheia.app.databinding.FragmentReaderHubBinding
+import com.aletheia.app.model.Book
+import com.aletheia.app.ui.main.MainActivity
+import com.aletheia.app.util.setBookCover
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ReaderHubFragment : Fragment() {
+ private var _binding: FragmentReaderHubBinding? = null
+ private val binding get() = _binding!!
+ private val app by lazy { requireActivity().application as AletheiaApplication }
+ private var currentBook: Book? = null
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
+ _binding = FragmentReaderHubBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, state: Bundle?) {
+ super.onViewCreated(view, state)
+ binding.continueReadingButton.setOnClickListener { currentBook?.let(::openBook) }
+ binding.currentBookCard.setOnClickListener { currentBook?.let(::openBook) }
+ binding.findBookButton.setOnClickListener {
+ (activity as? MainActivity)?.selectTab(R.id.nav_search)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ loadState()
+ }
+
+ override fun onDestroyView() {
+ _binding = null
+ super.onDestroyView()
+ }
+
+ private fun loadState() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ val books = withContext(Dispatchers.IO) { app.bookRepository.getAllBooks() }
+ if (_binding == null) return@launch
+ currentBook = books.filter { it.readingProgress > 0.0 }.maxByOrNull { it.lastRead }
+ val book = currentBook
+ binding.readerEmptyState.visibility = if (book == null) View.VISIBLE else View.GONE
+ binding.currentBookCard.visibility = if (book == null) View.GONE else View.VISIBLE
+ binding.downloadedCount.text = getString(R.string.reader_hub_downloaded_count, books.size)
+ if (book != null) {
+ binding.currentBookCover.setBookCover(book.coverImage, R.drawable.default_cover)
+ binding.currentBookTitle.text = book.title
+ binding.currentBookAuthor.text = book.author
+ val percent = (book.readingProgress * 100).toInt().coerceIn(0, 100)
+ binding.currentBookProgress.progress = percent
+ binding.currentBookProgressText.text = getString(R.string.reader_progress_percent, percent)
+ }
+ }
+ }
+
+ private fun openBook(book: Book) {
+ startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
+ }
+
+ companion object {
+ fun newInstance() = ReaderHubFragment()
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderPaginationCache.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderPaginationCache.kt
new file mode 100644
index 0000000..42fa094
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderPaginationCache.kt
@@ -0,0 +1,89 @@
+package com.aletheia.app.ui.reader
+
+import android.content.Context
+import android.util.AtomicFile
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.security.MessageDigest
+
+class ReaderPaginationCache(context: Context) {
+ private val directory = File(context.cacheDir, CACHE_DIRECTORY).apply { mkdirs() }
+
+ fun key(file: File, preferences: ReaderPreferences, viewportWidth: Int, viewportHeight: Int): String {
+ val canonical = file.canonicalFile
+ val signature = listOf(
+ ENGINE_VERSION,
+ canonical.name,
+ canonical.length(),
+ canonical.lastModified(),
+ viewportWidth,
+ viewportHeight,
+ preferences.fontName,
+ preferences.fontSize,
+ preferences.lineHeight,
+ preferences.margin,
+ preferences.textAlign,
+ preferences.verticalScroll
+ ).joinToString("|")
+ return MessageDigest.getInstance("SHA-256")
+ .digest(signature.toByteArray(Charsets.UTF_8))
+ .joinToString("") { "%02x".format(it) }
+ }
+
+ @Synchronized
+ fun read(key: String): String? = runCatching {
+ val atomic = AtomicFile(cacheFile(key))
+ atomic.openRead().use { input ->
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var totalBytes = 0L
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ totalBytes += read
+ if (totalBytes > MAX_CACHE_BYTES) return@runCatching null
+ output.write(buffer, 0, read)
+ }
+ output.toString(Charsets.UTF_8.name()).takeIf(String::isNotBlank)
+ }
+ }.getOrNull()
+
+ @Synchronized
+ fun write(key: String, locations: String) {
+ val bytes = locations.toByteArray(Charsets.UTF_8)
+ if (locations.isBlank() || bytes.size > MAX_CACHE_BYTES) return
+ directory.mkdirs()
+ val atomic = AtomicFile(cacheFile(key))
+ val output = atomic.startWrite()
+ try {
+ output.write(bytes)
+ output.flush()
+ atomic.finishWrite(output)
+ } catch (error: Exception) {
+ atomic.failWrite(output)
+ throw error
+ }
+ prune()
+ }
+
+ private fun prune() {
+ directory.listFiles()
+ ?.filter { file -> file.isFile && CACHE_FILE_NAME.matches(file.name) }
+ ?.sortedByDescending(File::lastModified)
+ ?.drop(MAX_CACHE_FILES)
+ ?.forEach { file -> AtomicFile(file).delete() }
+ }
+
+ private fun cacheFile(key: String): File {
+ require(key.matches(Regex("[a-f0-9]{64}")))
+ return File(directory, "$key.locations")
+ }
+
+ companion object {
+ private const val ENGINE_VERSION = "reader-v2-1"
+ private const val CACHE_DIRECTORY = "reader-layout"
+ private const val MAX_CACHE_FILES = 12
+ private const val MAX_CACHE_BYTES = 5L * 1024L * 1024L
+ private val CACHE_FILE_NAME = Regex("^[a-f0-9]{64}\\.locations$")
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderPreferences.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderPreferences.kt
new file mode 100644
index 0000000..712fe80
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderPreferences.kt
@@ -0,0 +1,230 @@
+package com.aletheia.app.ui.reader
+
+import com.aletheia.app.data.SettingsRepository
+import org.json.JSONObject
+
+data class ReaderPreferences(
+ val fontName: String = DEFAULT_FONT,
+ val fontSize: Int = 20,
+ val lineHeight: Double = 1.55,
+ val margin: Int = 13,
+ val textAlign: String = ALIGN_JUSTIFY,
+ val theme: String = THEME_LIGHT,
+ val verticalScroll: Boolean = false,
+ val pageTurnMode: String = PAGE_TURN_TAP_SWIPE,
+ val invertZones: Boolean = false,
+ val brightnessGesture: Boolean = false,
+ val systemBrightness: Boolean = false,
+ val brightness: Int = 100,
+ val orientation: String = ORIENTATION_AUTO,
+ val volumeButtons: Boolean = true,
+ val keepScreenOn: Boolean = false,
+ val showTitle: Boolean = true,
+ val showStatus: Boolean = false
+) {
+ fun hasSameEnginePreferences(other: ReaderPreferences): Boolean =
+ fontName == other.fontName &&
+ fontSize == other.fontSize &&
+ lineHeight == other.lineHeight &&
+ margin == other.margin &&
+ textAlign == other.textAlign &&
+ theme == other.theme &&
+ verticalScroll == other.verticalScroll &&
+ pageTurnMode == other.pageTurnMode &&
+ invertZones == other.invertZones &&
+ brightnessGesture == other.brightnessGesture
+
+ fun toEngineJson(): JSONObject = JSONObject()
+ .put("fontFamily", cssFontFamily(fontName))
+ .put("fontSize", fontSize.coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE))
+ .put("lineHeight", lineHeight.coerceIn(MIN_LINE_HEIGHT, MAX_LINE_HEIGHT))
+ .put("margin", margin.coerceIn(MIN_MARGIN, MAX_MARGIN))
+ .put("textAlign", textAlign)
+ .put("theme", theme)
+ .put("verticalScroll", verticalScroll)
+ .put("pageTurnMode", pageTurnMode)
+ .put("invertZones", invertZones)
+ .put("brightnessGesture", brightnessGesture)
+
+ fun toStateJson(): String = JSONObject()
+ .put("fontName", fontName)
+ .put("fontSize", fontSize)
+ .put("lineHeight", lineHeight)
+ .put("margin", margin)
+ .put("textAlign", textAlign)
+ .put("theme", theme)
+ .put("verticalScroll", verticalScroll)
+ .put("pageTurnMode", pageTurnMode)
+ .put("invertZones", invertZones)
+ .put("brightnessGesture", brightnessGesture)
+ .put("systemBrightness", systemBrightness)
+ .put("brightness", brightness)
+ .put("orientation", orientation)
+ .put("volumeButtons", volumeButtons)
+ .put("keepScreenOn", keepScreenOn)
+ .put("showTitle", showTitle)
+ .put("showStatus", showStatus)
+ .toString()
+
+ fun persist(settings: SettingsRepository) {
+ settings.setAll(
+ mapOf(
+ SettingsRepository.KEY_DEFAULT_FONT_FAMILY to fontName,
+ SettingsRepository.KEY_DEFAULT_FONT_SIZE to fontSize.toString(),
+ SettingsRepository.KEY_READER_LINE_HEIGHT to lineHeight.toString(),
+ SettingsRepository.KEY_READER_MARGIN to margin.toString(),
+ SettingsRepository.KEY_READER_ALIGNMENT to textAlign,
+ SettingsRepository.KEY_THEME to theme,
+ SettingsRepository.KEY_READER_VERTICAL_SCROLL to verticalScroll.toString(),
+ SettingsRepository.KEY_READER_PAGE_TURN_MODE to pageTurnMode,
+ SettingsRepository.KEY_READER_INVERT_ZONES to invertZones.toString(),
+ SettingsRepository.KEY_READER_BRIGHTNESS_GESTURE to brightnessGesture.toString(),
+ SettingsRepository.KEY_READER_SYSTEM_BRIGHTNESS to systemBrightness.toString(),
+ SettingsRepository.KEY_BRIGHTNESS to brightness.toDouble().toString(),
+ SettingsRepository.KEY_READER_ORIENTATION to orientation,
+ SettingsRepository.KEY_READER_VOLUME_BUTTONS to volumeButtons.toString(),
+ SettingsRepository.KEY_READER_KEEP_SCREEN_ON to keepScreenOn.toString(),
+ SettingsRepository.KEY_READER_SHOW_TITLE to showTitle.toString(),
+ SettingsRepository.KEY_READER_SHOW_STATUS to showStatus.toString()
+ )
+ )
+ }
+
+ companion object {
+ const val THEME_LIGHT = "light"
+ const val THEME_SEPIA = "sepia"
+ const val THEME_DARK = "dark"
+ const val ALIGN_JUSTIFY = "justify"
+ const val ALIGN_LEFT = "left"
+ const val PAGE_TURN_TAP_SWIPE = "tapSwipe"
+ const val PAGE_TURN_SWIPE = "swipe"
+ const val PAGE_TURN_TAP = "tap"
+ const val ORIENTATION_AUTO = "auto"
+ const val ORIENTATION_PORTRAIT = "portrait"
+ const val ORIENTATION_LANDSCAPE = "landscape"
+ const val DEFAULT_FONT = "Droid Serif"
+ const val MIN_FONT_SIZE = 12
+ const val MAX_FONT_SIZE = 42
+ const val MIN_LINE_HEIGHT = 1.1
+ const val MAX_LINE_HEIGHT = 2.4
+ const val MIN_MARGIN = 8
+ const val MAX_MARGIN = 72
+
+ val FONT_NAMES = listOf(
+ "Droid Serif",
+ "EB Garamond",
+ "Droid Sans",
+ "Roboto",
+ "PT Sans",
+ "PT Serif",
+ "Merriweather",
+ "Open Sans"
+ )
+
+ fun from(settings: SettingsRepository): ReaderPreferences {
+ val values = settings.getAll(READER_SETTING_KEYS)
+ fun string(key: String, fallback: String = "") = values[key] ?: fallback
+ fun int(key: String, fallback: Int) = values[key]?.toIntOrNull() ?: fallback
+ fun double(key: String, fallback: Double) = values[key]?.toDoubleOrNull() ?: fallback
+ fun boolean(key: String, fallback: Boolean = false) =
+ values[key]?.toBooleanStrictOrNull() ?: fallback
+
+ return ReaderPreferences(
+ fontName = string(SettingsRepository.KEY_DEFAULT_FONT_FAMILY, DEFAULT_FONT)
+ .takeIf(FONT_NAMES::contains) ?: DEFAULT_FONT,
+ fontSize = int(SettingsRepository.KEY_DEFAULT_FONT_SIZE, 20)
+ .coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE),
+ lineHeight = double(SettingsRepository.KEY_READER_LINE_HEIGHT, 1.55)
+ .coerceIn(MIN_LINE_HEIGHT, MAX_LINE_HEIGHT),
+ margin = int(SettingsRepository.KEY_READER_MARGIN, 13)
+ .coerceIn(MIN_MARGIN, MAX_MARGIN),
+ textAlign = string(SettingsRepository.KEY_READER_ALIGNMENT, ALIGN_JUSTIFY)
+ .takeIf { it == ALIGN_JUSTIFY || it == ALIGN_LEFT } ?: ALIGN_JUSTIFY,
+ theme = string(SettingsRepository.KEY_THEME, THEME_LIGHT)
+ .takeIf { it == THEME_LIGHT || it == THEME_SEPIA || it == THEME_DARK }
+ ?: THEME_LIGHT,
+ verticalScroll = boolean(SettingsRepository.KEY_READER_VERTICAL_SCROLL),
+ pageTurnMode = string(SettingsRepository.KEY_READER_PAGE_TURN_MODE, PAGE_TURN_TAP_SWIPE)
+ .takeIf { it == PAGE_TURN_TAP_SWIPE || it == PAGE_TURN_SWIPE || it == PAGE_TURN_TAP }
+ ?: PAGE_TURN_TAP_SWIPE,
+ invertZones = boolean(SettingsRepository.KEY_READER_INVERT_ZONES),
+ brightnessGesture = boolean(SettingsRepository.KEY_READER_BRIGHTNESS_GESTURE),
+ systemBrightness = boolean(SettingsRepository.KEY_READER_SYSTEM_BRIGHTNESS),
+ brightness = double(SettingsRepository.KEY_BRIGHTNESS, 100.0).toInt().coerceIn(10, 100),
+ orientation = string(SettingsRepository.KEY_READER_ORIENTATION, ORIENTATION_AUTO)
+ .takeIf { it == ORIENTATION_AUTO || it == ORIENTATION_PORTRAIT || it == ORIENTATION_LANDSCAPE }
+ ?: ORIENTATION_AUTO,
+ volumeButtons = boolean(SettingsRepository.KEY_READER_VOLUME_BUTTONS, true),
+ keepScreenOn = boolean(SettingsRepository.KEY_READER_KEEP_SCREEN_ON),
+ showTitle = boolean(SettingsRepository.KEY_READER_SHOW_TITLE, true),
+ showStatus = boolean(SettingsRepository.KEY_READER_SHOW_STATUS)
+ )
+ }
+
+ fun fromStateJson(raw: String?): ReaderPreferences? {
+ if (raw.isNullOrBlank()) return null
+ return runCatching {
+ val json = JSONObject(raw)
+ ReaderPreferences(
+ fontName = json.optString("fontName", DEFAULT_FONT)
+ .takeIf(FONT_NAMES::contains) ?: DEFAULT_FONT,
+ fontSize = json.optInt("fontSize", 20).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE),
+ lineHeight = json.optDouble("lineHeight", 1.55)
+ .coerceIn(MIN_LINE_HEIGHT, MAX_LINE_HEIGHT),
+ margin = json.optInt("margin", 13).coerceIn(MIN_MARGIN, MAX_MARGIN),
+ textAlign = json.optString("textAlign", ALIGN_JUSTIFY)
+ .takeIf { it == ALIGN_JUSTIFY || it == ALIGN_LEFT } ?: ALIGN_JUSTIFY,
+ theme = json.optString("theme", THEME_LIGHT)
+ .takeIf { it == THEME_LIGHT || it == THEME_SEPIA || it == THEME_DARK }
+ ?: THEME_LIGHT,
+ verticalScroll = json.optBoolean("verticalScroll", false),
+ pageTurnMode = json.optString("pageTurnMode", PAGE_TURN_TAP_SWIPE)
+ .takeIf { it == PAGE_TURN_TAP_SWIPE || it == PAGE_TURN_SWIPE || it == PAGE_TURN_TAP }
+ ?: PAGE_TURN_TAP_SWIPE,
+ invertZones = json.optBoolean("invertZones", false),
+ brightnessGesture = json.optBoolean("brightnessGesture", false),
+ systemBrightness = json.optBoolean("systemBrightness", false),
+ brightness = json.optInt("brightness", 100).coerceIn(10, 100),
+ orientation = json.optString("orientation", ORIENTATION_AUTO)
+ .takeIf { it == ORIENTATION_AUTO || it == ORIENTATION_PORTRAIT || it == ORIENTATION_LANDSCAPE }
+ ?: ORIENTATION_AUTO,
+ volumeButtons = json.optBoolean("volumeButtons", true),
+ keepScreenOn = json.optBoolean("keepScreenOn", false),
+ showTitle = json.optBoolean("showTitle", true),
+ showStatus = json.optBoolean("showStatus", false)
+ )
+ }.getOrNull()
+ }
+
+ private fun cssFontFamily(name: String): String = when (name) {
+ "EB Garamond" -> "'EB Garamond', Georgia, serif"
+ "Droid Sans" -> "'Droid Sans', Roboto, sans-serif"
+ "Roboto" -> "Roboto, sans-serif"
+ "PT Sans" -> "'PT Sans', Roboto, sans-serif"
+ "PT Serif" -> "'PT Serif', 'Noto Serif', serif"
+ "Merriweather" -> "Merriweather, 'Noto Serif', serif"
+ "Open Sans" -> "'Open Sans', Roboto, sans-serif"
+ else -> "'Droid Serif', 'Noto Serif', serif"
+ }
+
+ private val READER_SETTING_KEYS = listOf(
+ SettingsRepository.KEY_DEFAULT_FONT_FAMILY,
+ SettingsRepository.KEY_DEFAULT_FONT_SIZE,
+ SettingsRepository.KEY_READER_LINE_HEIGHT,
+ SettingsRepository.KEY_READER_MARGIN,
+ SettingsRepository.KEY_READER_ALIGNMENT,
+ SettingsRepository.KEY_THEME,
+ SettingsRepository.KEY_READER_VERTICAL_SCROLL,
+ SettingsRepository.KEY_READER_PAGE_TURN_MODE,
+ SettingsRepository.KEY_READER_INVERT_ZONES,
+ SettingsRepository.KEY_READER_BRIGHTNESS_GESTURE,
+ SettingsRepository.KEY_READER_SYSTEM_BRIGHTNESS,
+ SettingsRepository.KEY_BRIGHTNESS,
+ SettingsRepository.KEY_READER_ORIENTATION,
+ SettingsRepository.KEY_READER_VOLUME_BUTTONS,
+ SettingsRepository.KEY_READER_KEEP_SCREEN_ON,
+ SettingsRepository.KEY_READER_SHOW_TITLE,
+ SettingsRepository.KEY_READER_SHOW_STATUS
+ )
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderStateCoordinator.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderStateCoordinator.kt
new file mode 100644
index 0000000..b275a23
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderStateCoordinator.kt
@@ -0,0 +1,62 @@
+package com.aletheia.app.ui.reader
+
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+class ReaderStateCoordinator {
+ private val nextRevision = AtomicLong(0L)
+ private val positions = ConcurrentHashMap()
+ private val persistedSignatures = ConcurrentHashMap()
+
+ fun publish(
+ bookId: Long,
+ progress: Double,
+ locator: String?,
+ chapter: String?,
+ currentPage: Int,
+ totalPages: Int,
+ chapterCurrentPage: Int,
+ chapterTotalPages: Int
+ ): ReaderSessionPosition {
+ val snapshot = ReaderSessionPosition(
+ revision = nextRevision.incrementAndGet(),
+ bookId = bookId,
+ progress = if (progress.isFinite()) progress.coerceIn(0.0, 1.0) else 0.0,
+ locator = locator,
+ chapter = chapter,
+ currentPage = currentPage.coerceAtLeast(1),
+ totalPages = totalPages.coerceAtLeast(1),
+ chapterCurrentPage = chapterCurrentPage.coerceAtLeast(1),
+ chapterTotalPages = chapterTotalPages.coerceAtLeast(1)
+ )
+ positions.compute(bookId) { _, current ->
+ if (current == null || snapshot.revision > current.revision) snapshot else current
+ }
+ return snapshot
+ }
+
+ fun latest(bookId: Long): ReaderSessionPosition? = positions[bookId]
+
+ fun lastPersistedSignature(bookId: Long): String? = persistedSignatures[bookId]
+
+ fun markPersisted(bookId: Long, signature: String) {
+ persistedSignatures[bookId] = signature
+ }
+
+ fun clear(bookId: Long) {
+ positions.remove(bookId)
+ persistedSignatures.remove(bookId)
+ }
+}
+
+data class ReaderSessionPosition(
+ val revision: Long,
+ val bookId: Long,
+ val progress: Double,
+ val locator: String?,
+ val chapter: String?,
+ val currentPage: Int,
+ val totalPages: Int,
+ val chapterCurrentPage: Int,
+ val chapterTotalPages: Int
+)
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/ReaderWebController.kt b/app/src/main/java/com/aletheia/app/ui/reader/ReaderWebController.kt
new file mode 100644
index 0000000..522cf48
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/ReaderWebController.kt
@@ -0,0 +1,422 @@
+package com.aletheia.app.ui.reader
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.webkit.JavascriptInterface
+import android.webkit.RenderProcessGoneDetail
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.webkit.WebViewAssetLoader
+import com.aletheia.app.model.ReadingNote
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import org.json.JSONArray
+import org.json.JSONObject
+import kotlin.coroutines.resume
+
+class ReaderWebController(
+ context: Context,
+ private val webView: WebView,
+ private val onEvent: (ReaderEvent) -> Unit
+) {
+ private val appContext = context.applicationContext
+ private val booksRoot = File(appContext.filesDir, BOOKS_DIRECTORY).canonicalFile
+ @Volatile private var currentBookResource: BookResource? = null
+ private var shellReady = false
+ private var pendingBook: PendingBook? = null
+ private var renderProcessGone = false
+
+ private val assetLoader = WebViewAssetLoader.Builder()
+ .addPathHandler(BOOK_PATH_PREFIX, CurrentBookPathHandler())
+ .addPathHandler(ASSET_PATH_PREFIX, WebViewAssetLoader.AssetsPathHandler(appContext))
+ .addPathHandler(RESOURCE_PATH_PREFIX, WebViewAssetLoader.ResourcesPathHandler(appContext))
+ .build()
+
+ init {
+ configureWebView()
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun configureWebView() {
+ webView.settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ allowFileAccess = false
+ allowContentAccess = false
+ allowFileAccessFromFileURLs = false
+ allowUniversalAccessFromFileURLs = false
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ loadsImagesAutomatically = true
+ mediaPlaybackRequiresUserGesture = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) safeBrowsingEnabled = true
+ }
+ webView.isVerticalScrollBarEnabled = false
+ webView.isHorizontalScrollBarEnabled = false
+ webView.addJavascriptInterface(Bridge(), BRIDGE_NAME)
+ webView.webViewClient = object : WebViewClient() {
+ override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
+ val uri = request?.url ?: return emptyResponse(404, "Not Found")
+ assetLoader.shouldInterceptRequest(uri)?.let { return it }
+ return when (uri.scheme?.lowercase(Locale.US)) {
+ "blob", "data", "about" -> super.shouldInterceptRequest(view, request)
+ else -> emptyResponse(403, "Blocked")
+ }
+ }
+
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+ val uri = request?.url ?: return true
+ if (
+ uri.scheme.equals("https", ignoreCase = true) &&
+ uri.host.equals(APP_ASSET_HOST, ignoreCase = true)
+ ) {
+ return false
+ }
+ validatedExternalUrl(uri)?.let { onEvent(ReaderEvent.ExternalLink(it)) }
+ return true
+ }
+
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ shellReady = false
+ renderProcessGone = false
+ super.onPageStarted(view, url, favicon)
+ }
+
+ override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
+ if (request?.isForMainFrame == true) {
+ onEvent(
+ ReaderEvent.Error(
+ stage = "shell.load",
+ code = error?.errorCode?.let { "WebViewError$it" } ?: "WebViewLoadError",
+ message = error?.description?.toString().orEmpty().ifBlank {
+ "Не удалось запустить читалку"
+ },
+ recoverable = true
+ )
+ )
+ }
+ }
+
+ override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
+ renderProcessGone = true
+ onEvent(
+ ReaderEvent.Error(
+ stage = "renderer",
+ code = if (detail?.didCrash() == true) "RenderProcessCrash" else "RenderProcessGone",
+ message = "Процесс отображения книги был перезапущен",
+ recoverable = true
+ )
+ )
+ return true
+ }
+ }
+ webView.loadUrl(READER_URL)
+ }
+
+ fun loadBook(
+ file: File,
+ format: String,
+ locator: String?,
+ progress: Double,
+ cachedLocations: String?,
+ preferences: ReaderPreferences,
+ title: String
+ ) {
+ val canonical = file.canonicalFile
+ require(canonical.parentFile == booksRoot) { "Книга находится вне внутреннего каталога приложения" }
+ require(canonical.isFile) { "Файл книги не найден" }
+ val normalizedFormat = format.lowercase(Locale.US)
+ require(normalizedFormat == "epub" || normalizedFormat == "fb2") {
+ "Поддерживаются только EPUB и FB2"
+ }
+ val mime = when (normalizedFormat) {
+ "epub" -> "application/epub+zip"
+ "fb2" -> "application/xml"
+ else -> error("Unsupported book format")
+ }
+ currentBookResource = BookResource(canonical, mime, normalizedFormat)
+ val compatibleLocator = validatedLocator(normalizedFormat, locator)
+ pendingBook = PendingBook(
+ normalizedFormat,
+ compatibleLocator,
+ normalizedProgress(progress),
+ cachedLocations,
+ preferences,
+ title
+ )
+ sendPendingBookIfReady()
+ }
+
+ fun setPreferences(preferences: ReaderPreferences) = call("setPreferences", preferences.toEngineJson())
+ fun next() = call("next")
+ fun previous() = call("previous")
+ fun goToProgress(progress: Double) = call("goToProgress", normalizedProgress(progress))
+ fun goToLocator(locator: String) {
+ val compatible = validatedLocator(currentBookResource?.format.orEmpty(), locator) ?: return
+ call("goToLocator", encodeLocator(compatible))
+ }
+ fun goToChapter(href: String) = call("goToChapter", href)
+ fun search(query: String) = call("search", query)
+ fun nextSearch() = call("nextSearch")
+ fun previousSearch() = call("previousSearch")
+ fun clearSearch() = call("clearSearch")
+ fun clearSelection() = call("clearSelection")
+ fun addHighlight(locator: String, color: String) {
+ val compatible = validatedLocator(currentBookResource?.format.orEmpty(), locator) ?: return
+ call("addHighlight", encodeLocator(compatible), color)
+ }
+
+ fun removeHighlight(locator: String) {
+ val compatible = validatedLocator(currentBookResource?.format.orEmpty(), locator) ?: return
+ call("removeHighlight", encodeLocator(compatible))
+ }
+
+ fun setHighlights(notes: List) {
+ val format = currentBookResource?.format ?: return
+ val highlights = JSONArray()
+ notes.asSequence()
+ .filter { !it.cfi.isNullOrBlank() && !it.highlightColor.isNullOrBlank() }
+ .forEach { note ->
+ val locator = validatedLocator(format, note.cfi) ?: return@forEach
+ highlights.put(
+ JSONObject()
+ .put("id", note.id.toString())
+ .put("locator", encodeLocator(locator))
+ .put("color", note.highlightColor)
+ )
+ }
+ call("setHighlights", highlights)
+ }
+
+ suspend fun getStateJson(): String? = withContext(Dispatchers.Main.immediate) {
+ withTimeoutOrNull(JS_TIMEOUT_MS) {
+ suspendCancellableCoroutine { continuation ->
+ webView.evaluateJavascript(
+ "window.ReaderV2 && window.ReaderV2.getStateJson ? window.ReaderV2.getStateJson() : null"
+ ) { value ->
+ if (continuation.isActive) continuation.resume(decodeJavascriptString(value))
+ }
+ }
+ }
+ }
+
+ suspend fun getSpeechPage(maxCharacters: Int): ReaderSpeechPage? =
+ withContext(Dispatchers.Main.immediate) {
+ withTimeoutOrNull(JS_TIMEOUT_MS) {
+ suspendCancellableCoroutine { continuation ->
+ val limit = maxCharacters.coerceIn(200, MAX_SPEECH_PAGE_CHARACTERS)
+ webView.evaluateJavascript(
+ "window.ReaderV2 && window.ReaderV2.getSpeechPageJson ? " +
+ "window.ReaderV2.getSpeechPageJson($limit) : null"
+ ) { value ->
+ val result = decodeJavascriptString(value)
+ ?.let { runCatching { JSONObject(it) }.getOrNull() }
+ ?.let(ReaderSpeechPage::fromJson)
+ if (continuation.isActive) continuation.resume(result)
+ }
+ }
+ }
+ }
+
+ fun reload(): Boolean {
+ if (renderProcessGone) return false
+ shellReady = false
+ webView.reload()
+ return true
+ }
+
+ fun updatePendingPosition(locator: String?, progress: Double, preferences: ReaderPreferences) {
+ val pending = pendingBook ?: return
+ pendingBook = pending.copy(
+ locator = validatedLocator(pending.format, locator),
+ progress = normalizedProgress(progress, pending.progress),
+ preferences = preferences
+ )
+ }
+
+ fun destroy() {
+ currentBookResource = null
+ pendingBook = null
+ webView.removeJavascriptInterface(BRIDGE_NAME)
+ webView.stopLoading()
+ webView.destroy()
+ }
+
+ private fun sendPendingBookIfReady() {
+ val pending = pendingBook ?: return
+ if (!shellReady || currentBookResource == null) return
+ val payload = JSONObject()
+ .put("url", BOOK_URL)
+ .put("format", pending.format.lowercase(Locale.US))
+ .put("locator", pending.locator?.let(::encodeLocator) ?: JSONObject.NULL)
+ .put("progress", normalizedProgress(pending.progress))
+ .put("cachedLocations", pending.cachedLocations ?: JSONObject.NULL)
+ .put("preferences", pending.preferences.toEngineJson())
+ .put("title", pending.title)
+ call("loadBook", payload)
+ }
+
+ private fun call(method: String, vararg arguments: Any?) {
+ val encoded = arguments.joinToString(",") { argument ->
+ when (argument) {
+ null -> "null"
+ is JSONObject, is JSONArray -> argument.toString()
+ is Number, is Boolean -> argument.toString()
+ else -> JSONObject.quote(argument.toString())
+ }
+ }
+ webView.post {
+ webView.evaluateJavascript(
+ "window.ReaderV2 && window.ReaderV2.$method && window.ReaderV2.$method($encoded)",
+ null
+ )
+ }
+ }
+
+ private fun encodeLocator(locator: String): Any =
+ runCatching { JSONObject(locator) }.getOrElse { locator }
+
+ private inner class Bridge {
+ @JavascriptInterface
+ fun postMessage(message: String) {
+ val event = ReaderEvent.parse(message) ?: return
+ webView.post {
+ if (event is ReaderEvent.ShellReady) {
+ shellReady = true
+ sendPendingBookIfReady()
+ }
+ onEvent(event)
+ }
+ }
+ }
+
+ private inner class CurrentBookPathHandler : WebViewAssetLoader.PathHandler {
+ override fun handle(path: String): WebResourceResponse? {
+ if (path.substringBefore('?').trim('/') != BOOK_RESOURCE_NAME) return null
+ val resource = currentBookResource ?: return emptyResponse(404, "Book not selected")
+ val file = resource.file
+ if (!file.isFile || file.canonicalFile.parentFile != booksRoot) {
+ return emptyResponse(404, "Book not found")
+ }
+ return WebResourceResponse(resource.mime, null, FileInputStream(file)).apply {
+ responseHeaders = mapOf(
+ "Cache-Control" to "no-store",
+ "X-Content-Type-Options" to "nosniff"
+ )
+ }
+ }
+ }
+
+ private data class PendingBook(
+ val format: String,
+ val locator: String?,
+ val progress: Double,
+ val cachedLocations: String?,
+ val preferences: ReaderPreferences,
+ val title: String
+ )
+
+ private data class BookResource(
+ val file: File,
+ val mime: String,
+ val format: String
+ )
+
+ companion object {
+ private const val BRIDGE_NAME = "AndroidBridge"
+ private const val BOOKS_DIRECTORY = "Books"
+ private const val APP_ASSET_HOST = "appassets.androidplatform.net"
+ private const val ASSET_PATH_PREFIX = "/assets/"
+ private const val RESOURCE_PATH_PREFIX = "/res/"
+ private const val BOOK_PATH_PREFIX = "/book/"
+ private const val BOOK_RESOURCE_NAME = "current"
+ private const val READER_URL = "https://$APP_ASSET_HOST/assets/reader_v2/index.html"
+ private const val BOOK_URL = "https://$APP_ASSET_HOST/book/$BOOK_RESOURCE_NAME"
+ private const val JS_TIMEOUT_MS = 5_000L
+ private const val MAX_SPEECH_PAGE_CHARACTERS = 3_500
+
+ private fun emptyResponse(status: Int, reason: String): WebResourceResponse =
+ WebResourceResponse(
+ "text/plain",
+ "UTF-8",
+ status,
+ reason,
+ emptyMap(),
+ ByteArrayInputStream(ByteArray(0))
+ )
+
+ private fun decodeJavascriptString(value: String?): String? {
+ if (value == null || value == "null" || value == "undefined") return null
+ return runCatching { JSONArray("[$value]").getString(0) }.getOrNull()
+ }
+
+ private fun normalizedProgress(progress: Double, fallback: Double = 0.0): Double =
+ if (progress.isFinite()) progress.coerceIn(0.0, 1.0) else fallback.coerceIn(0.0, 1.0)
+
+ internal fun validatedLocator(format: String, locator: String?): String? {
+ val raw = locator?.trim()?.takeIf(String::isNotEmpty) ?: return null
+ val objectLocator = runCatching { JSONObject(raw) }.getOrNull()
+ return when (format) {
+ "epub" -> when {
+ objectLocator != null &&
+ objectLocator.optString("type") == "epub" &&
+ objectLocator.optString("cfi").startsWith("epubcfi(") -> objectLocator.toString()
+ objectLocator == null && raw.startsWith("epubcfi(") -> raw
+ else -> null
+ }
+ "fb2" -> when {
+ objectLocator != null &&
+ objectLocator.optString("type") == "fb2" &&
+ objectLocator.optString("sectionId").isNotBlank() &&
+ objectLocator.optInt("offset", -1) >= 0 -> objectLocator.toString()
+ objectLocator == null && FB2_LEGACY_LOCATOR.matches(raw) -> raw
+ else -> null
+ }
+ else -> null
+ }
+ }
+
+ private fun validatedExternalUrl(uri: Uri): String? = when (uri.scheme?.lowercase(Locale.US)) {
+ "http", "https" -> uri.toString().takeIf { !uri.host.isNullOrBlank() }
+ "mailto" -> uri.toString().takeIf { !uri.schemeSpecificPart.isNullOrBlank() }
+ else -> null
+ }
+
+ private val FB2_LEGACY_LOCATOR = Regex("^fb2:[^:]+:\\d+(?::\\d+)?$")
+ }
+}
+
+data class ReaderSpeechPage(
+ val text: String,
+ val locator: String?,
+ val canAdvance: Boolean
+) {
+ companion object {
+ internal fun fromJson(json: JSONObject): ReaderSpeechPage? {
+ val text = json.optString("text").trim()
+ if (text.isBlank()) return null
+ val locator = when (val value = json.opt("locator")) {
+ is JSONObject -> value.toString()
+ is String -> value.takeIf(String::isNotBlank)
+ else -> null
+ }
+ return ReaderSpeechPage(
+ text = text,
+ locator = locator,
+ canAdvance = json.optBoolean("canAdvance", true)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/tts/ReaderSpeechController.kt b/app/src/main/java/com/aletheia/app/ui/reader/tts/ReaderSpeechController.kt
new file mode 100644
index 0000000..c3deb7b
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/tts/ReaderSpeechController.kt
@@ -0,0 +1,246 @@
+package com.aletheia.app.ui.reader.tts
+
+import android.content.Context
+import android.media.AudioAttributes
+import android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
+import com.aletheia.app.ui.reader.ReaderSpeechPage
+import com.aletheia.app.ui.reader.ReaderWebController
+import java.util.Locale
+import java.util.UUID
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * Reads book pages through an installed Russian voice that explicitly declares
+ * that it does not require a network connection.
+ *
+ * The controller deliberately refuses network-only voices. A project-owned
+ * neural model can replace this engine behind the same reader-page contract.
+ */
+class ReaderSpeechController(
+ context: Context,
+ private val scope: CoroutineScope,
+ private val reader: ReaderWebController,
+ private val onStateChanged: (State) -> Unit,
+ private val onMessage: (String) -> Unit
+) {
+ enum class State { IDLE, INITIALIZING, PLAYING, PAUSED, ERROR }
+
+ private val appContext = context.applicationContext
+ private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ private var tts: TextToSpeech? = null
+ private var state = State.IDLE
+ private var initializationComplete = false
+ private var resumeAfterInitialization = false
+ private var playbackJob: Job? = null
+ private var currentPage: ReaderSpeechPage? = null
+ private var lastSpokenSignature: String? = null
+
+ val rate: Float
+ get() = preferences.getFloat(KEY_RATE, DEFAULT_RATE).coerceIn(MIN_RATE, MAX_RATE)
+
+ val pitch: Float
+ get() = preferences.getFloat(KEY_PITCH, DEFAULT_PITCH).coerceIn(MIN_PITCH, MAX_PITCH)
+
+ fun toggle() {
+ when (state) {
+ State.PLAYING, State.INITIALIZING -> pause()
+ State.IDLE, State.PAUSED, State.ERROR -> start()
+ }
+ }
+
+ fun start() {
+ if (!initializationComplete) {
+ resumeAfterInitialization = true
+ initialize()
+ return
+ }
+ val resumePausedPage = state == State.PAUSED
+ updateState(State.PLAYING)
+ speakCurrentPage(reusePausedPage = resumePausedPage)
+ }
+
+ fun pause() {
+ resumeAfterInitialization = false
+ playbackJob?.cancel()
+ playbackJob = null
+ tts?.stop()
+ updateState(State.PAUSED)
+ }
+
+ fun stop() {
+ resumeAfterInitialization = false
+ playbackJob?.cancel()
+ playbackJob = null
+ tts?.stop()
+ currentPage = null
+ lastSpokenSignature = null
+ updateState(State.IDLE)
+ }
+
+ fun updateSettings(rate: Float, pitch: Float) {
+ preferences.edit()
+ .putFloat(KEY_RATE, rate.coerceIn(MIN_RATE, MAX_RATE))
+ .putFloat(KEY_PITCH, pitch.coerceIn(MIN_PITCH, MAX_PITCH))
+ .apply()
+ applySpeechSettings()
+ if (state == State.PLAYING) {
+ tts?.stop()
+ speakCurrentPage(reusePausedPage = true)
+ }
+ }
+
+ fun destroy() {
+ playbackJob?.cancel()
+ playbackJob = null
+ tts?.stop()
+ tts?.shutdown()
+ tts = null
+ initializationComplete = false
+ }
+
+ private fun initialize() {
+ if (tts != null) {
+ updateState(State.INITIALIZING)
+ return
+ }
+ updateState(State.INITIALIZING)
+ tts = TextToSpeech(appContext) { status ->
+ if (status != TextToSpeech.SUCCESS) {
+ fail("Не удалось запустить синтез речи на устройстве")
+ return@TextToSpeech
+ }
+ val engine = tts ?: return@TextToSpeech
+ val russianLocale = Locale.Builder().setLanguage("ru").setRegion("RU").build()
+ if (engine.setLanguage(russianLocale) < TextToSpeech.LANG_AVAILABLE) {
+ fail("Русский язык не поддерживается установленным синтезатором речи")
+ return@TextToSpeech
+ }
+ val offlineRussianVoice = engine.voices
+ ?.asSequence()
+ ?.filter { it.locale.language.equals("ru", ignoreCase = true) }
+ ?.filterNot { it.isNetworkConnectionRequired }
+ ?.sortedWith(compareByDescending { it.quality }.thenBy { it.name })
+ ?.firstOrNull()
+ if (offlineRussianVoice == null) {
+ fail("На телефоне не установлен русский офлайн-голос")
+ return@TextToSpeech
+ }
+ if (engine.setVoice(offlineRussianVoice) == TextToSpeech.ERROR) {
+ fail("Не удалось выбрать русский офлайн-голос")
+ return@TextToSpeech
+ }
+ engine.setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+ )
+ engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
+ override fun onStart(utteranceId: String?) = Unit
+
+ override fun onDone(utteranceId: String?) {
+ scope.launch { advanceAfterPage() }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onError(utteranceId: String?) {
+ scope.launch { fail("Ошибка синтеза речи") }
+ }
+
+ override fun onError(utteranceId: String?, errorCode: Int) {
+ scope.launch { fail("Ошибка синтеза речи: $errorCode") }
+ }
+ })
+ initializationComplete = true
+ applySpeechSettings()
+ if (resumeAfterInitialization) {
+ resumeAfterInitialization = false
+ updateState(State.PLAYING)
+ speakCurrentPage(reusePausedPage = false)
+ } else {
+ updateState(State.IDLE)
+ }
+ }
+ }
+
+ private fun speakCurrentPage(reusePausedPage: Boolean) {
+ playbackJob?.cancel()
+ playbackJob = scope.launch {
+ val page = if (reusePausedPage) currentPage else null
+ ?: reader.getSpeechPage(TextToSpeech.getMaxSpeechInputLength().coerceAtMost(MAX_PAGE_CHARS))
+ if (state != State.PLAYING) return@launch
+ if (page == null || page.text.isBlank()) {
+ fail("На текущей странице нет текста для озвучивания")
+ return@launch
+ }
+ currentPage = page
+ val signature = page.signature()
+ if (!reusePausedPage && signature == lastSpokenSignature) {
+ stop()
+ onMessage("Достигнут конец книги")
+ return@launch
+ }
+ lastSpokenSignature = signature
+ applySpeechSettings()
+ val result = tts?.speak(
+ page.text,
+ TextToSpeech.QUEUE_FLUSH,
+ null,
+ UUID.randomUUID().toString()
+ ) ?: TextToSpeech.ERROR
+ if (result == TextToSpeech.ERROR) fail("Не удалось начать озвучивание")
+ }
+ }
+
+ private suspend fun advanceAfterPage() {
+ if (state != State.PLAYING) return
+ val page = currentPage ?: return
+ if (!page.canAdvance) {
+ stop()
+ onMessage("Книга прочитана до конца")
+ return
+ }
+ currentPage = null
+ reader.next()
+ delay(PAGE_TURN_DELAY_MS)
+ if (state == State.PLAYING) speakCurrentPage(reusePausedPage = false)
+ }
+
+ private fun applySpeechSettings() {
+ tts?.setSpeechRate(rate)
+ tts?.setPitch(pitch)
+ }
+
+ private fun fail(message: String) {
+ playbackJob?.cancel()
+ playbackJob = null
+ tts?.stop()
+ updateState(State.ERROR)
+ onMessage(message)
+ }
+
+ private fun updateState(next: State) {
+ state = next
+ onStateChanged(next)
+ }
+
+ private fun ReaderSpeechPage.signature(): String = "$locator|${text.take(160)}"
+
+ companion object {
+ const val MIN_RATE = 0.55f
+ const val MAX_RATE = 1.80f
+ const val MIN_PITCH = 0.75f
+ const val MAX_PITCH = 1.30f
+ private const val DEFAULT_RATE = 1.0f
+ private const val DEFAULT_PITCH = 1.0f
+ private const val PREFERENCES_NAME = "reader_speech"
+ private const val KEY_RATE = "rate"
+ private const val KEY_PITCH = "pitch"
+ private const val MAX_PAGE_CHARS = 3_500
+ private const val PAGE_TURN_DELAY_MS = 450L
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/reader/tts/ReaderSpeechSettingsDialog.kt b/app/src/main/java/com/aletheia/app/ui/reader/tts/ReaderSpeechSettingsDialog.kt
new file mode 100644
index 0000000..855dda9
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/ui/reader/tts/ReaderSpeechSettingsDialog.kt
@@ -0,0 +1,101 @@
+package com.aletheia.app.ui.reader.tts
+
+import android.content.Context
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.SeekBar
+import android.widget.TextView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.util.Locale
+import kotlin.math.roundToInt
+
+object ReaderSpeechSettingsDialog {
+ fun show(context: Context, controller: ReaderSpeechController) {
+ val density = context.resources.displayMetrics.density
+ val content = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ val horizontal = (24 * density).roundToInt()
+ val vertical = (8 * density).roundToInt()
+ setPadding(horizontal, vertical, horizontal, vertical)
+ }
+ val rateValue = TextView(context)
+ val rate = slider(
+ context = context,
+ label = "Скорость",
+ value = controller.rate,
+ min = ReaderSpeechController.MIN_RATE,
+ max = ReaderSpeechController.MAX_RATE,
+ valueView = rateValue,
+ parent = content
+ )
+ val pitchValue = TextView(context)
+ val pitch = slider(
+ context = context,
+ label = "Интонация",
+ value = controller.pitch,
+ min = ReaderSpeechController.MIN_PITCH,
+ max = ReaderSpeechController.MAX_PITCH,
+ valueView = pitchValue,
+ parent = content
+ )
+
+ MaterialAlertDialogBuilder(context)
+ .setTitle("Озвучивание книги")
+ .setMessage("Голос работает офлайн. Интонация регулирует высоту голоса.")
+ .setView(content)
+ .setNegativeButton("Отмена", null)
+ .setPositiveButton("Сохранить") { _, _ ->
+ controller.updateSettings(
+ progressToValue(rate.progress, ReaderSpeechController.MIN_RATE, ReaderSpeechController.MAX_RATE),
+ progressToValue(pitch.progress, ReaderSpeechController.MIN_PITCH, ReaderSpeechController.MAX_PITCH)
+ )
+ }
+ .show()
+ }
+
+ private fun slider(
+ context: Context,
+ label: String,
+ value: Float,
+ min: Float,
+ max: Float,
+ valueView: TextView,
+ parent: LinearLayout
+ ): SeekBar {
+ val title = TextView(context).apply {
+ text = label
+ textSize = 15f
+ setPadding(0, 16, 0, 0)
+ }
+ val seekBar = SeekBar(context).apply {
+ this.max = SLIDER_STEPS
+ progress = valueToProgress(value, min, max)
+ layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ }
+ fun render(progress: Int) {
+ valueView.text = String.format(
+ Locale.getDefault(),
+ "%.2f×",
+ progressToValue(progress, min, max)
+ )
+ }
+ seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) = render(progress)
+ override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
+ override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
+ })
+ render(seekBar.progress)
+ parent.addView(title)
+ parent.addView(valueView)
+ parent.addView(seekBar)
+ return seekBar
+ }
+
+ private fun valueToProgress(value: Float, min: Float, max: Float): Int =
+ (((value.coerceIn(min, max) - min) / (max - min)) * SLIDER_STEPS).roundToInt()
+
+ private fun progressToValue(progress: Int, min: Float, max: Float): Float =
+ min + (max - min) * progress.coerceIn(0, SLIDER_STEPS) / SLIDER_STEPS
+
+ private const val SLIDER_STEPS = 100
+}
diff --git a/app/src/main/java/com/aletheia/app/ui/settings/SettingsFragment.kt b/app/src/main/java/com/aletheia/app/ui/settings/SettingsFragment.kt
index 0dfe3b4..c97500e 100644
--- a/app/src/main/java/com/aletheia/app/ui/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/aletheia/app/ui/settings/SettingsFragment.kt
@@ -1,8 +1,6 @@
package com.aletheia.app.ui.settings
import android.content.ActivityNotFoundException
-import android.content.ClipData
-import android.content.ClipboardManager
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.Color
@@ -20,9 +18,9 @@ import com.aletheia.app.R
import com.aletheia.app.data.QBooksUrlPolicy
import com.aletheia.app.data.SettingsRepository
import com.aletheia.app.databinding.FragmentSettingsBinding
+import com.aletheia.app.ui.reader.ReaderPreferences
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.text.SimpleDateFormat
-import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -50,17 +48,7 @@ class SettingsFragment : Fragment() {
)
private val availableFontSizes = listOf(12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40)
- private val availableFonts = listOf(
- "serif",
- "sans-serif",
- "monospace",
- "Georgia",
- "Palatino",
- "Times New Roman",
- "Arial",
- "Verdana",
- "Courier New"
- )
+ private val availableFonts = ReaderPreferences.FONT_NAMES
private lateinit var availableThemes: List
private var availableUpdate: ArgusAvailableUpdate? = null
private var latestManifest: ArgusManifest? = null
@@ -99,19 +87,15 @@ class SettingsFragment : Fragment() {
binding.saveSettingsButton.setOnClickListener { saveSettings(showNotification = true) }
binding.checkUpdatesButton.setOnClickListener { checkForUpdates() }
binding.installUpdateButton.setOnClickListener { handleUpdateAction() }
- binding.copyDiagnosticsButton.setOnClickListener { copyLatestDiagnostics() }
- binding.clearDiagnosticsButton.setOnClickListener { clearDiagnostics() }
renderQBooksOverview()
renderUpdateSection()
- renderDiagnosticsSection()
}
override fun onResume() {
super.onResume()
loadSettings()
renderUpdateSection()
- renderDiagnosticsSection()
}
override fun onPause() {
@@ -130,12 +114,7 @@ class SettingsFragment : Fragment() {
val settings = app.settingsRepository
val storedTheme = settings.getString(SettingsRepository.KEY_THEME, "sepia")
- binding.qbooksUrlInput.setText(
- settings.getString(
- SettingsRepository.KEY_QBOOKS_URL,
- settings.getString(SettingsRepository.KEY_LEGACY_CATALOG_URL)
- )
- )
+ binding.qbooksUrlInput.setText(settings.getCatalogUrl())
binding.qbooksUsernameInput.setText(
settings.getString(
SettingsRepository.KEY_QBOOKS_USERNAME,
@@ -143,12 +122,22 @@ class SettingsFragment : Fragment() {
)
)
binding.qbooksPasswordInput.setText(settings.getSecurePassword())
- binding.fontSizeDropdown.setText(settings.getInt(SettingsRepository.KEY_DEFAULT_FONT_SIZE, 18).toString(), false)
- binding.fontFamilyDropdown.setText(settings.getString(SettingsRepository.KEY_DEFAULT_FONT_FAMILY, "serif"), false)
+ binding.fontSizeDropdown.setText(settings.getInt(SettingsRepository.KEY_DEFAULT_FONT_SIZE, 20).toString(), false)
+ binding.fontFamilyDropdown.setText(
+ settings.getString(SettingsRepository.KEY_DEFAULT_FONT_FAMILY, ReaderPreferences.DEFAULT_FONT)
+ .takeIf(availableFonts::contains) ?: ReaderPreferences.DEFAULT_FONT,
+ false
+ )
binding.themeDropdown.setText(themeLabelFromStored(storedTheme), false)
- val brightness = settings.getDouble(SettingsRepository.KEY_BRIGHTNESS, 100.0).toFloat()
- binding.brightnessSlider.value = brightness
+ val brightnessSlider = binding.brightnessSlider
+ val storedBrightness = settings.getDouble(SettingsRepository.KEY_BRIGHTNESS, 100.0).toFloat()
+ val brightness = if (storedBrightness.isFinite()) {
+ storedBrightness.coerceIn(brightnessSlider.valueFrom, brightnessSlider.valueTo)
+ } else {
+ brightnessSlider.valueTo
+ }
+ brightnessSlider.value = brightness
binding.brightnessValueText.text = getString(R.string.reader_brightness_value, brightness.toDouble())
updateSecurityHint()
@@ -171,8 +160,7 @@ class SettingsFragment : Fragment() {
binding.updateCurrentVersionText.text = getString(
R.string.settings_update_current_version_value,
- installedApp.versionName ?: "?",
- installedApp.versionCode
+ installedApp.versionName ?: "?"
)
renderUpdateOverview(installedApp, canRequestPackageInstalls)
@@ -226,28 +214,6 @@ class SettingsFragment : Fragment() {
}
}
- private fun renderDiagnosticsSection() {
- val binding = _binding ?: return
- val report = app.diagnosticsReporter.latestReport()
- if (report == null) {
- binding.diagnosticsStatusText.text = getString(R.string.settings_diagnostics_empty_title)
- binding.diagnosticsDetailText.text = getString(R.string.settings_diagnostics_empty_detail)
- binding.copyDiagnosticsButton.isEnabled = false
- binding.clearDiagnosticsButton.isEnabled = false
- renderDiagnosticsOverview(null)
- return
- }
-
- binding.diagnosticsStatusText.text = getString(R.string.settings_diagnostics_latest_title, report.summary)
- binding.diagnosticsDetailText.text = getString(
- R.string.settings_diagnostics_latest_detail,
- formatDiagnosticTimestamp(report.createdAt)
- )
- binding.copyDiagnosticsButton.isEnabled = true
- binding.clearDiagnosticsButton.isEnabled = true
- renderDiagnosticsOverview(report.summary)
- }
-
private fun renderQBooksOverview() {
val binding = _binding ?: return
val url = binding.qbooksUrlInput.text?.toString().orEmpty().trim()
@@ -291,8 +257,7 @@ class SettingsFragment : Fragment() {
val text = if (release == null) {
getString(
R.string.settings_overview_update_current,
- installedApp.versionName ?: "?",
- versionCodeText(installedApp.versionCode)
+ installedApp.versionName ?: "?"
)
} else {
overviewUpdateCurrentText(release)
@@ -302,8 +267,7 @@ class SettingsFragment : Fragment() {
else -> {
getString(
R.string.settings_overview_update_idle,
- installedApp.versionName ?: "?",
- installedApp.versionCode
+ installedApp.versionName ?: "?"
) to Color.parseColor("#6E5648")
}
}
@@ -312,43 +276,6 @@ class SettingsFragment : Fragment() {
binding.settingsUpdateOverviewDetailText.setTextColor(color)
}
- private fun renderDiagnosticsOverview(summary: String?) {
- val binding = _binding ?: return
- if (summary == null) {
- binding.settingsDiagnosticsOverviewDetailText.text = getString(R.string.settings_overview_diagnostics_empty)
- binding.settingsDiagnosticsOverviewDetailText.setTextColor(Color.parseColor("#2F7D5A"))
- } else {
- binding.settingsDiagnosticsOverviewDetailText.text = getString(R.string.settings_overview_diagnostics_report, summary)
- binding.settingsDiagnosticsOverviewDetailText.setTextColor(Color.parseColor("#9A672B"))
- }
- }
-
- private fun copyLatestDiagnostics() {
- val report = app.diagnosticsReporter.latestReport() ?: return
- val clipboard = requireContext().getSystemService(ClipboardManager::class.java)
- clipboard.setPrimaryClip(
- ClipData.newPlainText(
- "Aletheia diagnostics",
- report.text
- )
- )
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.settings_diagnostics_section)
- .setMessage(R.string.settings_diagnostics_copied)
- .setPositiveButton(R.string.action_ok, null)
- .show()
- }
-
- private fun clearDiagnostics() {
- app.diagnosticsReporter.clearReports()
- renderDiagnosticsSection()
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.settings_diagnostics_section)
- .setMessage(R.string.settings_diagnostics_cleared)
- .setPositiveButton(R.string.action_ok, null)
- .show()
- }
-
private fun checkForUpdates() {
if (isCheckingUpdates || isInstallingUpdate) {
return
@@ -480,8 +407,7 @@ class SettingsFragment : Fragment() {
detail = if (release == null) {
getString(
R.string.settings_update_status_up_to_date_detail,
- installedApp.versionName ?: "?",
- versionCodeText(installedApp.versionCode)
+ installedApp.versionName ?: "?"
)
} else {
updateCurrentDetailText(release)
@@ -522,56 +448,23 @@ class SettingsFragment : Fragment() {
}
private fun releaseVersionText(release: ArgusRelease): String =
- release.resolvedVersionCode?.let { versionCode ->
- getString(
- R.string.settings_update_release_value,
- release.resolvedVersionName,
- versionCodeText(versionCode)
- )
- } ?: getString(R.string.settings_update_release_value_without_code, release.resolvedVersionName)
+ getString(R.string.settings_update_release_value, release.resolvedVersionName)
private fun overviewUpdateAvailableText(release: ArgusRelease): String =
- release.resolvedVersionCode?.let { versionCode ->
- getString(
- R.string.settings_overview_update_available,
- release.resolvedVersionName,
- versionCodeText(versionCode)
- )
- } ?: getString(R.string.settings_overview_update_available_version_only, release.resolvedVersionName)
+ getString(R.string.settings_overview_update_available, release.resolvedVersionName)
private fun overviewUpdateCurrentText(release: ArgusRelease): String =
- release.resolvedVersionCode?.let { versionCode ->
- getString(
- R.string.settings_overview_update_current,
- release.resolvedVersionName,
- versionCodeText(versionCode)
- )
- } ?: getString(R.string.settings_overview_update_current_version_only, release.resolvedVersionName)
+ getString(R.string.settings_overview_update_current, release.resolvedVersionName)
private fun updateAvailableDetailText(release: ArgusRelease): String =
- release.resolvedVersionCode?.let { versionCode ->
- getString(
- R.string.settings_update_status_available_detail,
- release.resolvedVersionName,
- versionCodeText(versionCode),
- getString(R.string.action_install_update)
- )
- } ?: getString(
- R.string.settings_update_status_available_detail_version_only,
+ getString(
+ R.string.settings_update_status_available_detail,
release.resolvedVersionName,
getString(R.string.action_install_update)
)
private fun updateCurrentDetailText(release: ArgusRelease): String =
- release.resolvedVersionCode?.let { versionCode ->
- getString(
- R.string.settings_update_status_up_to_date_detail,
- release.resolvedVersionName,
- versionCodeText(versionCode)
- )
- } ?: getString(R.string.settings_update_status_up_to_date_detail_version_only, release.resolvedVersionName)
-
- private fun versionCodeText(value: Long?): String = value?.toString() ?: "?"
+ getString(R.string.settings_update_status_up_to_date_detail, release.resolvedVersionName)
private fun loadInstalledApp(): ArgusInstalledApp {
val context = requireContext().applicationContext
@@ -618,15 +511,13 @@ class SettingsFragment : Fragment() {
}
}
- private fun formatDiagnosticTimestamp(value: Long): String =
- SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.forLanguageTag("ru")).format(Date(value))
-
private fun saveSettings(showNotification: Boolean) {
val url = binding.qbooksUrlInput.text?.toString().orEmpty().trim()
val username = binding.qbooksUsernameInput.text?.toString().orEmpty().trim()
val password = binding.qbooksPasswordInput.text?.toString().orEmpty()
- val fontSize = binding.fontSizeDropdown.text?.toString()?.toIntOrNull() ?: 18
- val fontFamily = binding.fontFamilyDropdown.text?.toString().orEmpty().ifBlank { "serif" }
+ val fontSize = binding.fontSizeDropdown.text?.toString()?.toIntOrNull() ?: 20
+ val fontFamily = binding.fontFamilyDropdown.text?.toString()
+ ?.takeIf(availableFonts::contains) ?: ReaderPreferences.DEFAULT_FONT
val theme = storedThemeFromLabel(binding.themeDropdown.text?.toString().orEmpty())
val brightness = binding.brightnessSlider.value.toDouble()
@@ -731,10 +622,10 @@ class SettingsFragment : Fragment() {
}
private fun themeLabelFromStored(theme: String): String =
- availableThemes.firstOrNull { it.storedValue == theme }?.label ?: getString(R.string.reader_warm_theme)
+ availableThemes.firstOrNull { it.storedValue == theme }?.label ?: getString(R.string.reader_light_theme)
private fun storedThemeFromLabel(label: String): String =
- availableThemes.firstOrNull { it.label == label }?.storedValue ?: "sepia"
+ availableThemes.firstOrNull { it.label == label }?.storedValue ?: ReaderPreferences.THEME_LIGHT
companion object {
private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz"
diff --git a/app/src/main/java/com/aletheia/app/util/BookSharing.kt b/app/src/main/java/com/aletheia/app/util/BookSharing.kt
new file mode 100644
index 0000000..c71f386
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/util/BookSharing.kt
@@ -0,0 +1,80 @@
+package com.aletheia.app.util
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import com.aletheia.app.R
+import com.aletheia.app.model.Book
+import com.aletheia.app.model.QBooksBook
+import java.io.File
+import java.io.FileNotFoundException
+import java.util.Locale
+
+object BookSharing {
+ fun shareDownloadedBook(context: Context, book: Book) {
+ val file = File(book.filePath)
+ if (!file.isFile) {
+ throw FileNotFoundException(context.getString(R.string.dialog_book_file_missing))
+ }
+
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.files",
+ file,
+ book.shareDisplayName()
+ )
+ val sendIntent = Intent(Intent.ACTION_SEND).apply {
+ type = mimeType(book.format)
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, book.title)
+ putExtra(Intent.EXTRA_TITLE, book.title)
+ clipData = ClipData.newUri(context.contentResolver, book.title, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ val chooser = Intent.createChooser(sendIntent, context.getString(R.string.share_book_chooser_title)).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(chooser)
+ }
+
+ fun shareBookLink(context: Context, book: QBooksBook) {
+ val url = book.shareUrl?.takeIf(String::isNotBlank)
+ ?: book.downloadUrl.takeIf(String::isNotBlank)
+ ?: error(context.getString(R.string.share_book_link_missing))
+ val text = buildString {
+ append(book.title)
+ book.author.takeIf(String::isNotBlank)?.let {
+ append('\n')
+ append(it)
+ }
+ append('\n')
+ append(url)
+ }
+ val sendIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_SUBJECT, book.title)
+ putExtra(Intent.EXTRA_TEXT, text)
+ }
+ context.startActivity(Intent.createChooser(sendIntent, context.getString(R.string.share_book_chooser_title)))
+ }
+
+ private fun mimeType(format: String): String = when (format.lowercase(Locale.US)) {
+ "epub" -> "application/epub+zip"
+ "fb2" -> "application/x-fictionbook+xml"
+ "pdf" -> "application/pdf"
+ else -> "application/octet-stream"
+ }
+
+ private fun Book.shareDisplayName(): String {
+ val extension = format.lowercase(Locale.US).ifBlank { "book" }
+ val originalName = fileName
+ .substringAfterLast('/')
+ .substringAfterLast('\\')
+ .trim()
+ .ifBlank { "$title.$extension" }
+ return originalName.replace(UNSAFE_FILE_NAME_CHARS, "_")
+ }
+
+ private val UNSAFE_FILE_NAME_CHARS = Regex("[\\u0000-\\u001F\\\\/:*?\"<>|]")
+}
diff --git a/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstaller.kt b/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstaller.kt
index 0fb824a..11be01b 100644
--- a/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstaller.kt
+++ b/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstaller.kt
@@ -41,7 +41,7 @@ class ArgusPackageInstaller(private val context: Context) {
expectedPackageName,
update.manifest.release.version,
expectedVersionCode,
- "APK содержит versionCode $archiveVersionCode, а Argus ожидал $expectedVersionCode."
+ "Версия APK не совпадает с опубликованным релизом."
)
}
if (archiveVersionCode <= update.installedApp.versionCode) {
@@ -49,7 +49,7 @@ class ArgusPackageInstaller(private val context: Context) {
expectedPackageName,
update.manifest.release.version,
expectedVersionCode ?: archiveVersionCode,
- "APK содержит versionCode $archiveVersionCode, он не выше установленного ${update.installedApp.versionCode}."
+ "Скачанный APK не новее установленного приложения."
)
}
diff --git a/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstallerStatusReceiver.kt b/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstallerStatusReceiver.kt
index 841cf20..e4662fb 100644
--- a/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstallerStatusReceiver.kt
+++ b/app/src/main/java/xyz/kusoft/argusupdater/ArgusPackageInstallerStatusReceiver.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
+import java.io.File
class ArgusPackageInstallerStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -21,6 +22,10 @@ class ArgusPackageInstallerStatusReceiver : BroadcastReceiver() {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ if (status != PackageInstaller.STATUS_PENDING_USER_ACTION) {
+ ArgusUpdateCache(File(context.cacheDir, ArgusUpdateCache.DIRECTORY_NAME)).clear()
+ }
+
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
preferences.saveInstallerEvent(
diff --git a/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateCache.kt b/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateCache.kt
new file mode 100644
index 0000000..5422a10
--- /dev/null
+++ b/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateCache.kt
@@ -0,0 +1,32 @@
+package xyz.kusoft.argusupdater
+
+import java.io.File
+
+internal class ArgusUpdateCache(private val directory: File) {
+ @Synchronized
+ fun prepareTarget(): File {
+ clear()
+ check(directory.mkdirs() || directory.isDirectory) {
+ "Не удалось подготовить каталог обновления."
+ }
+
+ val target = File(directory, PACKAGE_FILE_NAME).canonicalFile
+ check(target.parentFile == directory.canonicalFile) {
+ "Некорректный путь временного APK."
+ }
+ return target
+ }
+
+ @Synchronized
+ fun clear() {
+ if (!directory.exists()) return
+
+ directory.listFiles()?.forEach { it.deleteRecursively() }
+ directory.delete()
+ }
+
+ companion object {
+ const val DIRECTORY_NAME = "argus-updater"
+ const val PACKAGE_FILE_NAME = "pending-update.apk"
+ }
+}
diff --git a/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateClient.kt b/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateClient.kt
index 9080ab1..37d6d6b 100644
--- a/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateClient.kt
+++ b/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateClient.kt
@@ -23,51 +23,69 @@ class ArgusUpdateClient {
}
fun downloadRelease(update: ArgusAvailableUpdate, targetFile: File): File {
- targetFile.parentFile?.mkdirs()
+ val targetDirectory = targetFile.parentFile
+ ?: throw IllegalArgumentException("Для временного APK не задан каталог.")
+ check(targetDirectory.mkdirs() || targetDirectory.isDirectory) {
+ "Не удалось подготовить каталог для загрузки обновления."
+ }
+
+ val partialFile = File(targetDirectory, "${targetFile.name}.part")
+ partialFile.delete()
+
if (targetFile.exists() && sha256(targetFile).equals(update.manifest.release.sha256, ignoreCase = true)) {
return targetFile
}
-
- val connection = openConnection(
- update.downloadUrl,
- accept = "application/vnd.android.package-archive, application/octet-stream, */*"
- )
- connection.requestMethod = "GET"
- connection.instanceFollowRedirects = true
+ targetFile.delete()
try {
- val statusCode = connection.responseCode
- if (statusCode != HttpURLConnection.HTTP_OK) {
- throw IllegalStateException("Argus не отдал APK: HTTP $statusCode.")
- }
+ val connection = openConnection(
+ update.downloadUrl,
+ accept = "application/vnd.android.package-archive, application/octet-stream, */*"
+ )
+ connection.requestMethod = "GET"
+ connection.instanceFollowRedirects = true
- connection.inputStream.use { input ->
- targetFile.outputStream().use { output ->
- input.copyTo(output)
+ try {
+ val statusCode = connection.responseCode
+ if (statusCode != HttpURLConnection.HTTP_OK) {
+ throw IllegalStateException("Argus не отдал APK: HTTP $statusCode.")
}
+
+ connection.inputStream.use { input ->
+ partialFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ } finally {
+ connection.disconnect()
}
- } finally {
- connection.disconnect()
- }
- val expectedBytes = update.manifest.release.packageSizeBytes.takeIf { it > 0 }
- if (expectedBytes != null && targetFile.length() != expectedBytes) {
- val actualBytes = targetFile.length()
+ val expectedBytes = update.manifest.release.packageSizeBytes.takeIf { it > 0 }
+ if (expectedBytes != null && partialFile.length() != expectedBytes) {
+ val actualBytes = partialFile.length()
+ throw IllegalStateException(
+ "Размер APK не совпал. Argus ожидал $expectedBytes байт, получено $actualBytes."
+ )
+ }
+
+ val actualSha = sha256(partialFile)
+ if (!actualSha.equals(update.manifest.release.sha256, ignoreCase = true)) {
+ throw IllegalStateException(
+ "Контрольная сумма APK не совпала. Argus ожидал ${update.manifest.release.sha256}, получено $actualSha."
+ )
+ }
+
+ if (!partialFile.renameTo(targetFile)) {
+ partialFile.copyTo(targetFile, overwrite = true)
+ partialFile.delete()
+ }
+
+ return targetFile
+ } catch (error: Exception) {
+ partialFile.delete()
targetFile.delete()
- throw IllegalStateException(
- "Размер APK не совпал. Argus ожидал $expectedBytes байт, получено $actualBytes."
- )
+ throw error
}
-
- val actualSha = sha256(targetFile)
- if (!actualSha.equals(update.manifest.release.sha256, ignoreCase = true)) {
- targetFile.delete()
- throw IllegalStateException(
- "Контрольная сумма APK не совпала. Argus ожидал ${update.manifest.release.sha256}, получено $actualSha."
- )
- }
-
- return targetFile
}
private fun buildManifestUrl(config: ArgusUpdateConfig): String {
diff --git a/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateManager.kt b/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateManager.kt
index 6f0b0bf..4a93307 100644
--- a/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateManager.kt
+++ b/app/src/main/java/xyz/kusoft/argusupdater/ArgusUpdateManager.kt
@@ -13,6 +13,14 @@ class ArgusUpdateManager(
private val installer: ArgusPackageInstaller = ArgusPackageInstaller(context),
private val preferences: ArgusUpdatePreferences = ArgusUpdatePreferences(context)
) {
+ private val updateCache = ArgusUpdateCache(
+ File(context.cacheDir, ArgusUpdateCache.DIRECTORY_NAME)
+ )
+
+ init {
+ updateCache.clear()
+ }
+
fun canRequestPackageInstalls(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
@@ -33,8 +41,8 @@ class ArgusUpdateManager(
fun loadLastInstallerEvent(): ArgusInstallerEvent? =
preferences.loadLastInstallerEvent()
- fun buildDownloadTargetFile(update: ArgusAvailableUpdate): File =
- File(context.cacheDir, "argus-updater/${update.manifest.release.originalFileName}")
+ fun buildDownloadTargetFile(@Suppress("UNUSED_PARAMETER") update: ArgusAvailableUpdate): File =
+ updateCache.prepareTarget()
fun checkForUpdate(
config: ArgusUpdateConfig,
@@ -63,7 +71,7 @@ class ArgusUpdateManager(
val versionComparison = ArgusUpdateVersionPolicy.compareReleaseToInstalled(manifest.release, installedApp)
?: return ArgusUpdateCheckResult.Incompatible(
- "Argus не вернул versionCode, а установленная сборка не содержит versionName для сравнения."
+ "Argus не вернул сведения, необходимые для сравнения версий."
)
if (versionComparison <= 0) {
@@ -85,9 +93,10 @@ class ArgusUpdateManager(
}
}
+ @Synchronized
fun downloadAndInstall(update: ArgusAvailableUpdate): ArgusInstallSubmissionResult {
+ val targetFile = buildDownloadTargetFile(update)
return try {
- val targetFile = buildDownloadTargetFile(update)
val apkFile = client.downloadRelease(update, targetFile)
installer.submitInstall(apkFile, update)
} catch (error: Exception) {
@@ -100,6 +109,8 @@ class ArgusUpdateManager(
detail = message
)
ArgusInstallSubmissionResult.Rejected(message)
+ } finally {
+ updateCache.clear()
}
}
}
diff --git a/app/src/main/res/color/bottom_nav_indicator_color.xml b/app/src/main/res/color/bottom_nav_indicator_color.xml
index c4a52d8..38006f2 100644
--- a/app/src/main/res/color/bottom_nav_indicator_color.xml
+++ b/app/src/main/res/color/bottom_nav_indicator_color.xml
@@ -1,4 +1,4 @@
-
+
diff --git a/app/src/main/res/color/bottom_nav_item_colors.xml b/app/src/main/res/color/bottom_nav_item_colors.xml
index 0ab2c2d..b64c073 100644
--- a/app/src/main/res/color/bottom_nav_item_colors.xml
+++ b/app/src/main/res/color/bottom_nav_item_colors.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/app/src/main/res/drawable/bg_book_detail_sheet.xml b/app/src/main/res/drawable/bg_book_detail_sheet.xml
new file mode 100644
index 0000000..320a66d
--- /dev/null
+++ b/app/src/main/res/drawable/bg_book_detail_sheet.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_bottom_navigation.xml b/app/src/main/res/drawable/bg_bottom_navigation.xml
index 47a5c62..ed8b5e9 100644
--- a/app/src/main/res/drawable/bg_bottom_navigation.xml
+++ b/app/src/main/res/drawable/bg_bottom_navigation.xml
@@ -1,8 +1,6 @@
-
-
-
+
+
+
diff --git a/app/src/main/res/drawable/bg_circle_white.xml b/app/src/main/res/drawable/bg_circle_white.xml
new file mode 100644
index 0000000..cd353eb
--- /dev/null
+++ b/app/src/main/res/drawable/bg_circle_white.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_gradient.xml b/app/src/main/res/drawable/bg_gradient.xml
index c6fe3fc..26fb5a5 100644
--- a/app/src/main/res/drawable/bg_gradient.xml
+++ b/app/src/main/res/drawable/bg_gradient.xml
@@ -1,8 +1,4 @@
-
+
diff --git a/app/src/main/res/drawable/bg_hero_card.xml b/app/src/main/res/drawable/bg_hero_card.xml
new file mode 100644
index 0000000..9fd4339
--- /dev/null
+++ b/app/src/main/res/drawable/bg_hero_card.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_banner_first.xml b/app/src/main/res/drawable/bg_home_banner_first.xml
new file mode 100644
index 0000000..7b5bd5d
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_banner_first.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_banner_overlay.xml b/app/src/main/res/drawable/bg_home_banner_overlay.xml
new file mode 100644
index 0000000..1a5015a
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_banner_overlay.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_banner_second.xml b/app/src/main/res/drawable/bg_home_banner_second.xml
new file mode 100644
index 0000000..a0fe361
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_banner_second.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_banner_third.xml b/app/src/main/res/drawable/bg_home_banner_third.xml
new file mode 100644
index 0000000..c2a91e9
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_banner_third.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_chip.xml b/app/src/main/res/drawable/bg_home_chip.xml
new file mode 100644
index 0000000..f593009
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_chip.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_format.xml b/app/src/main/res/drawable/bg_home_format.xml
new file mode 100644
index 0000000..56b7f12
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_format.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_mini_button.xml b/app/src/main/res/drawable/bg_home_mini_button.xml
new file mode 100644
index 0000000..ed61922
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_mini_button.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_home_search.xml b/app/src/main/res/drawable/bg_home_search.xml
new file mode 100644
index 0000000..3e7bee7
--- /dev/null
+++ b/app/src/main/res/drawable/bg_home_search.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_qbooks_shelf_status.xml b/app/src/main/res/drawable/bg_qbooks_shelf_status.xml
index 396ae4f..88298bf 100644
--- a/app/src/main/res/drawable/bg_qbooks_shelf_status.xml
+++ b/app/src/main/res/drawable/bg_qbooks_shelf_status.xml
@@ -1,6 +1,6 @@
-
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_control.xml b/app/src/main/res/drawable/bg_reader_v2_control.xml
new file mode 100644
index 0000000..0942145
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_control.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_mode_selectable.xml b/app/src/main/res/drawable/bg_reader_v2_mode_selectable.xml
new file mode 100644
index 0000000..da59b50
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_mode_selectable.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_option_selectable.xml b/app/src/main/res/drawable/bg_reader_v2_option_selectable.xml
new file mode 100644
index 0000000..4de7cda
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_option_selectable.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_row.xml b/app/src/main/res/drawable/bg_reader_v2_row.xml
new file mode 100644
index 0000000..fcf69e1
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_row.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_selected_option.xml b/app/src/main/res/drawable/bg_reader_v2_selected_option.xml
new file mode 100644
index 0000000..8e5978c
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_selected_option.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_sheet.xml b/app/src/main/res/drawable/bg_reader_v2_sheet.xml
new file mode 100644
index 0000000..05cee96
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_sheet.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_tab.xml b/app/src/main/res/drawable/bg_reader_v2_tab.xml
new file mode 100644
index 0000000..e137a7e
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_tab.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_tab_selected.xml b/app/src/main/res/drawable/bg_reader_v2_tab_selected.xml
new file mode 100644
index 0000000..9d13dd1
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_tab_selected.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_tab_unselected.xml b/app/src/main/res/drawable/bg_reader_v2_tab_unselected.xml
new file mode 100644
index 0000000..ecdde57
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_tab_unselected.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_theme_dark.xml b/app/src/main/res/drawable/bg_reader_v2_theme_dark.xml
new file mode 100644
index 0000000..320d998
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_theme_dark.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_theme_light.xml b/app/src/main/res/drawable/bg_reader_v2_theme_light.xml
new file mode 100644
index 0000000..ac994b0
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_theme_light.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_reader_v2_theme_sepia.xml b/app/src/main/res/drawable/bg_reader_v2_theme_sepia.xml
new file mode 100644
index 0000000..7f6db40
--- /dev/null
+++ b/app/src/main/res/drawable/bg_reader_v2_theme_sepia.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_search_pill.xml b/app/src/main/res/drawable/bg_search_pill.xml
new file mode 100644
index 0000000..70b6e7e
--- /dev/null
+++ b/app/src/main/res/drawable/bg_search_pill.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_categories.xml b/app/src/main/res/drawable/ic_home_categories.xml
new file mode 100644
index 0000000..0e316f8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_categories.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_books.xml b/app/src/main/res/drawable/ic_nav_books.xml
new file mode 100644
index 0000000..8959437
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_books.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_home.xml b/app/src/main/res/drawable/ic_nav_home.xml
new file mode 100644
index 0000000..1f8984f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_home.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_profile.xml b/app/src/main/res/drawable/ic_nav_profile.xml
new file mode 100644
index 0000000..906abec
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_profile.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_reader.xml b/app/src/main/res/drawable/ic_nav_reader.xml
new file mode 100644
index 0000000..2255ba4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_reader.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_search.xml b/app/src/main/res/drawable/ic_nav_search.xml
new file mode 100644
index 0000000..2221b5d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_search.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_align_justify.xml b/app/src/main/res/drawable/ic_reader_v2_align_justify.xml
new file mode 100644
index 0000000..42d9bb3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_align_justify.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_align_left.xml b/app/src/main/res/drawable/ic_reader_v2_align_left.xml
new file mode 100644
index 0000000..96fbcf6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_align_left.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_back.xml b/app/src/main/res/drawable/ic_reader_v2_back.xml
new file mode 100644
index 0000000..035f4f6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_back.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_bookmark.xml b/app/src/main/res/drawable/ic_reader_v2_bookmark.xml
new file mode 100644
index 0000000..4bd4249
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_bookmark.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_brightness.xml b/app/src/main/res/drawable/ic_reader_v2_brightness.xml
new file mode 100644
index 0000000..85ed516
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_brightness.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_check.xml b/app/src/main/res/drawable/ic_reader_v2_check.xml
new file mode 100644
index 0000000..6a56963
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_check.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_chevron_down.xml b/app/src/main/res/drawable/ic_reader_v2_chevron_down.xml
new file mode 100644
index 0000000..3632fe0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_chevron_down.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_close.xml b/app/src/main/res/drawable/ic_reader_v2_close.xml
new file mode 100644
index 0000000..ecf3249
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_close.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_contents.xml b/app/src/main/res/drawable/ic_reader_v2_contents.xml
new file mode 100644
index 0000000..2820d7e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_contents.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_copy.xml b/app/src/main/res/drawable/ic_reader_v2_copy.xml
new file mode 100644
index 0000000..0478b4c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_copy.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_delete.xml b/app/src/main/res/drawable/ic_reader_v2_delete.xml
new file mode 100644
index 0000000..9b3a230
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_delete.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_line_looser.xml b/app/src/main/res/drawable/ic_reader_v2_line_looser.xml
new file mode 100644
index 0000000..332f221
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_line_looser.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_line_tighter.xml b/app/src/main/res/drawable/ic_reader_v2_line_tighter.xml
new file mode 100644
index 0000000..e8eaab5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_line_tighter.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_margin_narrower.xml b/app/src/main/res/drawable/ic_reader_v2_margin_narrower.xml
new file mode 100644
index 0000000..e666129
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_margin_narrower.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_margin_wider.xml b/app/src/main/res/drawable/ic_reader_v2_margin_wider.xml
new file mode 100644
index 0000000..0a7b73e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_margin_wider.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_minus.xml b/app/src/main/res/drawable/ic_reader_v2_minus.xml
new file mode 100644
index 0000000..e8bb289
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_minus.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_note.xml b/app/src/main/res/drawable/ic_reader_v2_note.xml
new file mode 100644
index 0000000..b11b6d5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_note.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_option_marker.xml b/app/src/main/res/drawable/ic_reader_v2_option_marker.xml
new file mode 100644
index 0000000..71b1cf8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_option_marker.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_plus.xml b/app/src/main/res/drawable/ic_reader_v2_plus.xml
new file mode 100644
index 0000000..4dd989a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_plus.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_quote.xml b/app/src/main/res/drawable/ic_reader_v2_quote.xml
new file mode 100644
index 0000000..bd37fdc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_quote.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_search.xml b/app/src/main/res/drawable/ic_reader_v2_search.xml
new file mode 100644
index 0000000..6c8fc64
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_search.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_settings.xml b/app/src/main/res/drawable/ic_reader_v2_settings.xml
new file mode 100644
index 0000000..9d305e4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_settings.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_share.xml b/app/src/main/res/drawable/ic_reader_v2_share.xml
new file mode 100644
index 0000000..fd458cd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_share.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_reader_v2_voice.xml b/app/src/main/res/drawable/ic_reader_v2_voice.xml
new file mode 100644
index 0000000..577e103
--- /dev/null
+++ b/app/src/main/res/drawable/ic_reader_v2_voice.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml
new file mode 100644
index 0000000..3ea9161
--- /dev/null
+++ b/app/src/main/res/drawable/ic_share.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_book_detail.xml b/app/src/main/res/layout/activity_book_detail.xml
new file mode 100644
index 0000000..0efe586
--- /dev/null
+++ b/app/src/main/res/layout/activity_book_detail.xml
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 37a159a..22d196a 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -3,32 +3,129 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="@drawable/bg_gradient">
+ android:background="@color/home_background">
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:background="@color/reader_v2_canvas"
+ android:theme="@style/ThemeOverlay.Aletheia.ReaderV2">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:background="@color/reader_v2_scrim"
+ android:clickable="true"
+ android:contentDescription="@string/reader_v2_close_quote_actions"
+ android:focusable="true" />
-
+
+
+ android:fillViewport="false"
+ android:overScrollMode="never">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:orientation="horizontal">
-
+
-
-
-
-
-
-
-
-
-
-
+ android:layout_marginStart="8dp"
+ android:text="@string/reader_v2_note_title"
+ android:textColor="@color/reader_v2_secondary_on_light"
+ android:textSize="15sp" />
-
-
-
-
-
-
+ android:layout_marginTop="12dp"
+ android:ellipsize="end"
+ android:maxLines="4"
+ android:textColor="@color/reader_v2_text"
+ android:textSize="16sp" />
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:gravity="top|start"
+ android:inputType="textCapSentences|textMultiLine"
+ android:minHeight="96dp"
+ android:textColor="@color/reader_v2_text"
+ android:textColorHint="@color/reader_v2_secondary_on_light"
+ android:textSize="15sp" />
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
diff --git a/app/src/main/res/layout/fragment_bookshelf.xml b/app/src/main/res/layout/fragment_bookshelf.xml
index 83fb8f5..e11d464 100644
--- a/app/src/main/res/layout/fragment_bookshelf.xml
+++ b/app/src/main/res/layout/fragment_bookshelf.xml
@@ -3,7 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="@drawable/bg_gradient"
+ android:background="@color/app_background"
android:clipToPadding="false"
android:fillViewport="true">
@@ -11,256 +11,77 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:paddingLeft="20dp"
- android:paddingTop="12dp"
- android:paddingRight="20dp"
- android:paddingBottom="28dp">
+ android:paddingStart="20dp"
+ android:paddingTop="16dp"
+ android:paddingEnd="20dp"
+ android:paddingBottom="30dp">
-
-
+ android:text="@string/library_title"
+ android:textColor="@color/ink_color"
+ android:textSize="30sp" />
-
-
-
-
+ android:textColor="@color/ink_soft_color"
+ android:textSize="14sp" />
+ app:startIconDrawable="@drawable/ic_nav_search"
+ app:startIconTint="@color/ink_soft_color">
+ android:singleLine="true"
+ android:textColor="@color/ink_color"
+ android:textColorHint="@color/ink_soft_color"
+ android:textSize="16sp" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:clipToPadding="false"
+ android:contentDescription="@string/a11y_books_list"
+ android:scrollbars="none" />
+
+
+
+
+
+
+
+
+
@@ -293,41 +301,14 @@
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:visibility="gone"
- app:cardBackgroundColor="#E60B1812"
+ app:cardBackgroundColor="@color/white"
app:cardCornerRadius="20dp"
- app:cardElevation="0dp"
- app:strokeColor="@color/night_border"
- app:strokeWidth="1dp">
+ app:cardElevation="0dp">
-
-
-
-
-
-
-
+
+
+
+
@@ -335,45 +316,10 @@
android:id="@+id/qbooks_home_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="28dp"
- android:orientation="vertical">
-
-
-
-
-
-
-
-
-
+ android:orientation="vertical"
+ android:visibility="gone">
+
+
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
new file mode 100644
index 0000000..702d164
--- /dev/null
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -0,0 +1,469 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_qbooks_library.xml b/app/src/main/res/layout/fragment_qbooks_library.xml
index 7de4501..2733e38 100644
--- a/app/src/main/res/layout/fragment_qbooks_library.xml
+++ b/app/src/main/res/layout/fragment_qbooks_library.xml
@@ -3,90 +3,142 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:orientation="vertical"
- android:paddingLeft="16dp"
- android:paddingTop="20dp"
- android:paddingRight="16dp">
+ android:background="@color/app_background"
+ android:orientation="vertical">
+ android:textSize="30sp" />
+ android:textSize="14sp" />
-
+ android:layout_height="54dp"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="16dp"
+ android:layout_marginEnd="16dp"
+ app:cardBackgroundColor="#F0F0F3"
+ app:cardCornerRadius="27dp"
+ app:cardElevation="0dp"
+ app:strokeWidth="0dp">
-
+
+
+
+
+
+
+
+
+
+
-
+ android:paddingStart="16dp"
+ android:paddingEnd="16dp"
+ app:chipSpacingHorizontal="8dp"
+ app:singleLine="true">
+
+
+
+
+
+
+
+
+
+ android:layout_height="44dp"
+ android:layout_marginStart="12dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginEnd="12dp"
+ android:gravity="end|center_vertical"
+ android:orientation="horizontal">
-
+ android:layout_width="wrap_content"
+ android:layout_height="44dp"
+ android:gravity="center"
+ android:paddingStart="10dp"
+ android:paddingEnd="10dp"
+ android:text="@string/action_refresh_catalog"
+ android:textColor="@color/accent_color"
+ android:textSize="13sp" />
-
-
-
+ android:layout_width="wrap_content"
+ android:layout_height="44dp"
+ android:gravity="center"
+ android:paddingStart="10dp"
+ android:paddingEnd="10dp"
+ android:text="@string/action_settings"
+ android:textColor="@color/ink_soft_color"
+ android:textSize="13sp" />
@@ -94,7 +146,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:padding="14dp">
+ android:padding="12dp">
+ android:textSize="13sp" />
+ android:textSize="12sp" />
+ android:layout_marginTop="3dp"
+ android:textColor="@color/accent_color"
+ android:textSize="11sp" />
@@ -126,34 +178,35 @@
android:id="@+id/download_status_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="14dp"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginBottom="8dp"
android:visibility="gone"
- app:cardBackgroundColor="@color/surface_muted"
- app:cardCornerRadius="18dp"
- app:strokeColor="@color/border_color"
- app:strokeWidth="1dp">
+ app:cardBackgroundColor="@color/accent_soft_color"
+ app:cardCornerRadius="14dp"
+ app:cardElevation="0dp">
+ android:padding="12dp">
+
+
-
-
+ android:textColor="@color/ink_color"
+ android:textSize="13sp" />
@@ -161,42 +214,13 @@
android:id="@+id/not_configured_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="14dp"
- android:visibility="gone"
- app:cardBackgroundColor="@color/surface_color"
- app:cardCornerRadius="18dp"
- app:strokeColor="@color/border_color"
- app:strokeWidth="1dp">
+ android:layout_margin="16dp"
+ android:visibility="gone">
-
-
-
-
-
-
-
+
+
+
+
@@ -204,71 +228,26 @@
android:id="@+id/error_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="14dp"
- android:visibility="gone"
- app:cardBackgroundColor="@color/surface_color"
- app:cardCornerRadius="18dp"
- app:strokeColor="@color/border_color"
- app:strokeWidth="1dp">
+ android:layout_margin="16dp"
+ android:visibility="gone">
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+ android:layout_weight="1">
-
@@ -278,44 +257,25 @@
android:layout_height="match_parent"
android:clipToPadding="false"
android:contentDescription="@string/a11y_qbooks_list"
- android:paddingBottom="32dp" />
+ android:paddingTop="4dp"
+ android:paddingBottom="20dp" />
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_reader_hub.xml b/app/src/main/res/layout/fragment_reader_hub.xml
new file mode 100644
index 0000000..a2ad411
--- /dev/null
+++ b/app/src/main/res/layout/fragment_reader_hub.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index 8481522..4ec7111 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -125,42 +125,16 @@
android:textSize="12sp" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/item_book.xml b/app/src/main/res/layout/item_book.xml
index 7ca56f0..7a74d88 100644
--- a/app/src/main/res/layout/item_book.xml
+++ b/app/src/main/res/layout/item_book.xml
@@ -3,10 +3,11 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="124dp"
android:layout_height="wrap_content"
+ android:layout_marginEnd="14dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
- app:cardBackgroundColor="#00101612"
+ app:cardBackgroundColor="@android:color/transparent"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
@@ -37,6 +38,23 @@
android:max="100"
android:progressBackgroundTint="#402A1A0C"
android:progressTint="@color/night_gold" />
+
+
+ android:paddingStart="14dp"
+ android:paddingTop="14dp"
+ android:paddingEnd="8dp"
+ android:paddingBottom="14dp">
+
+
-
-
+
+
-
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/reader_v2_delete"
+ android:padding="11dp"
+ android:src="@drawable/ic_reader_v2_delete" />
diff --git a/app/src/main/res/layout/item_chapter.xml b/app/src/main/res/layout/item_chapter.xml
index 704ef46..b0a5f22 100644
--- a/app/src/main/res/layout/item_chapter.xml
+++ b/app/src/main/res/layout/item_chapter.xml
@@ -3,57 +3,67 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="10dp"
- app:cardBackgroundColor="@color/surface_color"
- app:cardCornerRadius="14dp"
- app:strokeColor="@color/border_color"
- app:strokeWidth="1dp">
+ android:minHeight="62dp"
+ app:cardBackgroundColor="@color/reader_v2_canvas"
+ app:cardCornerRadius="0dp"
+ app:cardElevation="0dp"
+ app:strokeWidth="0dp">
-
-
-
+ android:textColor="@color/reader_v2_text"
+ android:textSize="16sp"
+ app:layout_constraintBottom_toTopOf="@id/chapter_current_text"
+ app:layout_constraintEnd_toStartOf="@id/chapter_index_text"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintVertical_chainStyle="packed" />
-
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/chapter_label_text" />
+
+
+
+
diff --git a/app/src/main/res/layout/item_qbooks_book.xml b/app/src/main/res/layout/item_qbooks_book.xml
index c442870..cb22105 100644
--- a/app/src/main/res/layout/item_qbooks_book.xml
+++ b/app/src/main/res/layout/item_qbooks_book.xml
@@ -3,32 +3,36 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="12dp"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginBottom="10dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
- app:cardBackgroundColor="@color/surface_color"
- app:cardCornerRadius="18dp"
- app:cardElevation="1dp"
+ app:cardBackgroundColor="@color/white"
+ app:cardCornerRadius="16dp"
+ app:cardElevation="0dp"
app:strokeColor="@color/border_color"
app:strokeWidth="1dp">
+ android:orientation="horizontal"
+ android:layout_weight="1">
@@ -79,9 +83,12 @@
diff --git a/app/src/main/res/layout/item_qbooks_shelf_book.xml b/app/src/main/res/layout/item_qbooks_shelf_book.xml
index 9f0f211..19e3bf3 100644
--- a/app/src/main/res/layout/item_qbooks_shelf_book.xml
+++ b/app/src/main/res/layout/item_qbooks_shelf_book.xml
@@ -1,13 +1,16 @@
@@ -18,52 +21,73 @@
+ android:layout_height="180dp"
+ android:clipChildren="false"
+ android:clipToPadding="false">
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:layout_gravity="top|end"
+ android:background="@drawable/bg_circle_white"
+ android:contentDescription="@null"
+ android:fontFamily="sans"
+ android:gravity="center"
+ android:importantForAccessibility="no"
+ android:text="♡"
+ android:textColor="@color/home_secondary"
+ android:textSize="32sp"
+ android:translationX="4dp" />
+ android:visibility="gone" />
-
+ android:visibility="gone" />
+
+
+
+
diff --git a/app/src/main/res/layout/item_qbooks_shelf_status.xml b/app/src/main/res/layout/item_qbooks_shelf_status.xml
index 44f199e..fa6a000 100644
--- a/app/src/main/res/layout/item_qbooks_shelf_status.xml
+++ b/app/src/main/res/layout/item_qbooks_shelf_status.xml
@@ -3,6 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="180dp"
android:layout_height="204dp"
+ android:layout_marginEnd="14dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
@@ -24,7 +25,7 @@
android:layout_height="56dp"
android:fontFamily="@font/open_sans_semibold"
android:gravity="center"
- android:text="Q"
+ android:text="А"
android:textColor="@color/night_gold"
android:textSize="34sp" />
diff --git a/app/src/main/res/layout/item_reading_note.xml b/app/src/main/res/layout/item_reading_note.xml
index 979c1b6..1372c1c 100644
--- a/app/src/main/res/layout/item_reading_note.xml
+++ b/app/src/main/res/layout/item_reading_note.xml
@@ -4,61 +4,72 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
- app:cardBackgroundColor="@color/surface_color"
- app:cardCornerRadius="14dp"
- app:strokeColor="@color/border_color"
+ android:foreground="?attr/selectableItemBackground"
+ app:cardBackgroundColor="@color/reader_v2_canvas"
+ app:cardCornerRadius="2dp"
+ app:cardElevation="2dp"
+ app:strokeColor="@color/reader_v2_separator"
app:strokeWidth="1dp">
+ android:paddingStart="14dp"
+ android:paddingTop="14dp"
+ android:paddingEnd="8dp"
+ android:paddingBottom="14dp">
+
+
-
-
-
-
+
+
+
+
-
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/reader_v2_delete"
+ android:padding="11dp"
+ android:src="@drawable/ic_reader_v2_delete" />
diff --git a/app/src/main/res/layout/item_shelf_add.xml b/app/src/main/res/layout/item_shelf_add.xml
index 69ec291..c0db9cb 100644
--- a/app/src/main/res/layout/item_shelf_add.xml
+++ b/app/src/main/res/layout/item_shelf_add.xml
@@ -7,7 +7,7 @@
android:contentDescription="@string/a11y_library_shelf_add"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
- app:cardBackgroundColor="#CC102119"
+ app:cardBackgroundColor="@color/white"
app:cardCornerRadius="14dp"
app:cardElevation="0dp"
app:strokeColor="@color/night_border"
diff --git a/app/src/main/res/menu/book_actions_menu.xml b/app/src/main/res/menu/book_actions_menu.xml
new file mode 100644
index 0000000..b9845a9
--- /dev/null
+++ b/app/src/main/res/menu/book_actions_menu.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml
index 4feb053..a222d06 100644
--- a/app/src/main/res/menu/bottom_nav_menu.xml
+++ b/app/src/main/res/menu/bottom_nav_menu.xml
@@ -1,15 +1,23 @@
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 284ceea..8f86d05 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,34 +1,46 @@
- #050D0A
- #020605
- #101B15
- #16251C
- #203729
- #FFF2D9
- #99866A
- #314331
- #1C6A4C
- #F7B35B
- #403A2A12
- #6DD59D
- #9A672B
- #B4493B
- #09120D
- #050D0A
- #020605
- #101B15
- #16251C
- #203729
- #314331
- #F7B35B
- #C29A66
- #FFF2D9
- #99866A
- #0F4A34
- #1C6A4C
- #9A5D2F
- #4C2918
+ #F7F7F9
+ #F1F1F4
+ #FFFFFF
+ #F3F3F6
+ #E9E9EE
+ #1A1A20
+ #6F6F78
+ #E2E2E8
+ #E83F4E
+ #D92D3C
+ #FFF0F2
+ #16855B
+ #A45E13
+ #C83342
+ #FFFFFF
+ #F7F7F9
+ #F1F1F4
+ #FFFFFF
+ #F3F3F6
+ #E9E9EE
+ #E2E2E8
+ #E83F4E
+ #B73542
+ #1A1A20
+ #6F6F78
+ #E83F4E
+ #F25A67
+ #D7D7DD
+ #B9B9C1
#FFFFFF
#000000
+
+
+ #FFFFFF
+ #F4F4FB
+ #E9EBF0
+ #14192C
+ #5B617A
+ #878CA0
+ #CCCED8
+ #EC622B
+ #FCEEE4
+ #303653
diff --git a/app/src/main/res/values/reader_colors.xml b/app/src/main/res/values/reader_colors.xml
new file mode 100644
index 0000000..2926893
--- /dev/null
+++ b/app/src/main/res/values/reader_colors.xml
@@ -0,0 +1,32 @@
+
+
+ #FFFFFF
+ #F4F4F5
+ #DDDDDD
+ #EC622B
+ #FFF2EC
+ #626262
+ #000000
+ #9D9C9F
+ #767579
+ #EC622B
+ #DCDCDD
+ #1A000000
+ #42000000
+ #F8F2E4
+ #000000
+ #B4BBBF
+ #F8D8A1
+ #AAD2A4
+ #A4CCFB
+ #C99BF9
+ #EC9EAC
+
+
+
diff --git a/app/src/main/res/values/reader_strings.xml b/app/src/main/res/values/reader_strings.xml
new file mode 100644
index 0000000..94270b9
--- /dev/null
+++ b/app/src/main/res/values/reader_strings.xml
@@ -0,0 +1,90 @@
+
+
+ Открываем книгу…
+ Не удалось открыть книгу
+ Повторить
+ Ещё — стр.
+ — из —
+ Название книги
+ Текущая глава
+ Настройки чтения
+ Слушать
+ Поиск по книге
+ Оглавление
+ Закладка
+ Закрыть
+ Яркость
+ Использовать системную яркость
+ Белый
+ Сепия
+ Чёрный
+ Размер шрифта
+ 20
+ Шрифт
+ Системный с засечками
+ Вертикальный скролл
+ Все настройки
+ Текст
+ Ориентация
+ Автоматически
+ Выравнивание
+ По ширине
+ По левому краю
+ Межстрочный интервал
+ 1,5
+ Поля
+ Средние
+ Управление чтением
+ Управление и жесты
+ Листание кнопками громкости
+ Инвертировать зоны перелистывания
+ Если листаете левой рукой
+ Изменение яркости жестом вверх-вниз
+ Не выключать экран при чтении
+ Цвета и яркость
+ Способ перелистывания
+ Нажатие и пролистывание
+ Коснитесь экрана или проведите пальцем
+ Только пролистывание
+ Проведите пальцем влево или вправо
+ Только нажатие
+ Коснитесь экрана для перелистывания
+ Показывать на странице
+ Название книги
+ Часы и заряд батареи устройства
+ ОГЛАВЛЕНИЕ
+ ЗАКЛАДКИ
+ ЦИТАТЫ
+ Оглавление появится после загрузки книги
+ Закладок пока нет
+ Сохранённых цитат пока нет
+ Действия с цитатой
+ Поделиться
+ Копировать
+ Добавить заметку
+ Без выделения
+ Жёлтое выделение
+ Зелёное выделение
+ Синее выделение
+ Фиолетовое выделение
+ Розовое выделение
+ СОХРАНИТЬ ЦИТАТУ
+ Заметка
+ Заметка будет видна только вам
+ ОТМЕНА
+ СОХРАНИТЬ
+ Удалить
+ Сейчас
+ Уменьшить
+ Увеличить
+ Назад
+ Положение в книге
+ Закрыть настройки чтения
+ Закрыть действия с цитатой
+ Уменьшить размер шрифта
+ Увеличить размер шрифта
+ Уменьшить межстрочный интервал
+ Увеличить межстрочный интервал
+ Уменьшить поля страницы
+ Увеличить поля страницы
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d13e803..6851a9c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,15 +2,21 @@
Алетея
- Библиотека
- QBooks
- Настройки
+ Мои книги
+ Поиск
+ Профиль
+ Главное
+ Поиск
+ Читалка
+ Мои книги
+ Профиль
Добавить файл
- Каталог QBooks
+ Каталог
Открыть
Читать
Удалить
+ Поделиться
Повторить
Обновить каталог
Сохранить
@@ -37,15 +43,13 @@
Назад
Вперед
Очистить поиск
- Скопировать отчёт
- Очистить отчёты
Главы
Закладка
Заметка
Поиск
Книга или автор
- Поиск по QBooks
+ Название, автор или серия
Адрес сервера
Логин
Пароль
@@ -68,9 +72,10 @@
Список заметок и цитат
Список закладок
Книжная полка
- Каталог QBooks
- Книга: %1$s. Автор: %2$s. Прогресс: %3$s. Открыть. Удерживайте, чтобы удалить.
- Книга из QBooks: %1$s. Автор: %2$s. %3$s. Скачать.
+ Каталог книг
+ Книга: %1$s. Автор: %2$s. Прогресс: %3$s. Открыть.
+ Действия с книгой %1$s
+ Книга: %1$s. Автор: %2$s. %3$s. Открыть карточку.
Скачать книгу %1$s
Глава: %1$s. Открыть.
Текущая глава: %1$s. Открыть.
@@ -80,80 +85,74 @@
Удалить заметку
Библиотека пока пуста
- Добавьте EPUB или FB2 с устройства либо подключитесь к QBooks, чтобы собрать первую полку.
+ Скачайте книгу из каталога или добавьте EPUB либо FB2 с устройства.
На полке ничего не найдено
По запросу «%1$s» нет книг. Очистите поиск или попробуйте имя автора.
Ничего не найдено
Измените запрос или обновите каталог, если на сервере появились новые книги.
- QBooks ещё не настроен
- Укажите адрес сервера, логин и пароль в настройках, после чего каталог появится здесь.
+ Каталог пока недоступен
+ Проверьте подключение к интернету и повторите попытку.
Подключение не удалось
Книга добавлена в библиотеку
Изменения сохранены.
- Библиотека
- Ваши книги всегда под рукой
+ Мои книги
+ Читайте офлайн и продолжайте с последней страницы
На полке
Листайте полку вбок. Обложка открывает чтение.
+ Цель чтения
+ Читайте каждый день — Алетея сохранит ваш прогресс
+ Все
+ Читаю
+ Скачано
+ Прочитано
+ В этом разделе пока нет книг.
Добавить книгу
EPUB или FB2 с устройства
Добавить EPUB или FB2 с устройства.
Все
СЕЙЧАС ЧИТАЮ
- QBooks
- QBooks
+ Новинки
+ Каталог
Загружаю книги каталога…
- QBooks ждёт подключения
- Откройте каталог и настройте сервер.
- В QBooks пока пусто
- Откройте каталог или обновите сервер.
- QBooks недоступен
- Откройте каталог и проверьте подключение.
- Открыть каталог QBooks.
- Каталог QBooks
- Ищите книги в QBooks, скачивайте их в одно касание и держите онлайн-каталог рядом с локальной полкой.
- Настройки
- Подключите QBooks и задайте параметры чтения, которые будут применяться ко всем новым книгам.
+ Каталог ждёт подключения
+ Проверьте интернет и повторите попытку.
+ В каталоге пока пусто
+ Обновите каталог немного позже.
+ Каталог недоступен
+ Проверьте подключение и повторите попытку.
+ Открыть каталог книг.
+ Поиск
+ Найдите книгу по названию, автору или серии
+ Профиль
+ Настройки чтения, обновления и данные приложения
Продолжить чтение
Библиотека
Прогресс
Состояние
- Главные сервисы собраны в одном месте: каталог, обновления и безопасная диагностика.
- QBooks
+ Каталог и обновления собраны в одном месте.
+ Каталог
Обновления Argus
- Диагностика
Не настроен. Каталог появится после адреса сервера.
Адрес требует проверки.
- Защищённое подключение: %1$s
- Локальный HTTP: %1$s
- Установлена %1$s (%2$d). Проверка запускается вручную.
+ Каталог подключён
+ Каталог подключён
+ Установлена %1$s. Проверка запускается вручную.
Проверяю Argus…
Готовлю установку…
- Доступна %1$s (%2$s).
- Доступна %1$s.
- Актуальна: %1$s (%2$s).
- Актуальна: %1$s.
+ Доступна %1$s.
+ Актуальна: %1$s.
Нужно разрешение Android на установку APK.
Релиз для этой сборки не опубликован.
Релиз несовместим с этим устройством.
Проверка обновления не удалась.
- Сбоев не зафиксировано.
- Есть локальный отчёт: %1$s
- Подключение к QBooks
+ Источник каталога
Чтение по умолчанию
- Диагностика
- Локальные отчёты помогают разбирать сбои. Они не отправляются автоматически и не содержат текст книг, пароли, URL или пути файлов.
- Отчётов пока нет
- Если приложение аварийно завершится, здесь появится безопасный технический отчёт для поддержки.
- Последний отчёт: %1$s
- Сохранён: %1$s. Можно скопировать отчёт и передать поддержке вручную.
- Диагностический отчёт скопирован.
- Диагностические отчёты очищены.
- Тёплая
- Светлая
- Тёмная
+ Сепия
+ Белый
+ Чёрный
Ожидание книги…
Поиск по главам
Фраза или слово
@@ -169,6 +168,7 @@
%1$d%%
Стр. %1$d из %2$d
Глава: %1$d из %2$d
+ Ещё %1$d стр.
Позиция внутри главы появится после перелистывания
Глава определяется
Положение чтения: %1$s. %2$s. %3$s. Нажмите, чтобы открыть меню чтения.
@@ -234,7 +234,7 @@
Удалить книгу
- Удалить “%1$s” из библиотеки?
+ Удалить “%1$s” из «Моих книг» вместе со скачанным файлом?
Ошибка
Готово
Настройки
@@ -243,32 +243,33 @@
Не удалось добавить книгу: %1$s
Не удалось загрузить библиотеку: %1$s
Не удалось удалить книгу: %1$s
+ Не удалось поделиться книгой: %1$s
Не удалось скачать книгу: %1$s
Не удалось загрузить книгу: %1$s
“%1$s” добавлена в библиотеку.
- https://qbooks.kusoft.xyz
+ Адрес каталога
Имя пользователя
Пароль
Проверяю соединение…
Соединение установлено. Настройки сохранены.
Сервер ответил ошибкой или недоступен.
- Введите адрес сервера QBooks.
+ Введите адрес каталога.
Адрес сервера должен быть полным URL, например https://server.example.
Поддерживаются HTTPS и локальный HTTP.
Ошибка проверки: %1$s
- Если QBooks доступен из интернета, используйте HTTPS. HTTP разрешён только для локального QBooks.
+ Для внешнего каталога используйте HTTPS. HTTP разрешён только для локального адреса.
Проверьте адрес сервера. Нужен полный URL с http:// или https://.
- HTTPS включён: логин и пароль передаются по зашифрованному каналу.
- Локальный HTTP разрешён, но трафик и пароль не шифруются. Для внешнего сервера используйте HTTPS.
- HTTP разрешён только для локального QBooks: localhost, 10.0.2.2, 192.168.0.185 или *.local. Для внешнего сервера используйте HTTPS.
+ Защищённое подключение к каталогу включено.
+ Локальное HTTP-подключение не шифруется.
+ Для внешнего каталога используйте HTTPS. HTTP разрешён только для локального адреса.
Поддерживаются только схемы http:// и https://.
Загрузка: %1$s
Загрузка: %1$.0f%%
Книга добавлена в библиотеку
Формат: %1$s
- Обновляю каталог QBooks…
+ Обновляю каталог…
Загружаю ещё книги…
По текущему запросу книг нет.
По запросу «%1$s» книг нет.
@@ -290,5 +291,51 @@
- По запросу «%2$s» показано %1$d книг
- По запросу «%2$s» показано %1$d книг
- Сохранён некорректный адрес QBooks: %1$s
+ Сохранён некорректный адрес каталога.
+
+ Искать в каталоге
+ Жанры
+ Что почитать
+ Новинки
+ Авторы
+ Новые миры
+ Фантастика и приключения
+ Книги недели
+ Свежие истории для чтения
+ Выбор читателей
+ Книги, к которым возвращаются
+ Новинки
+ Вы читали
+ Скачанные книги появятся здесь
+ Текст
+ Открыть книгу «%1$s»
+ Читать
+ Жанры
+ Фантастика
+ Детективы
+ Романы
+ Фэнтези
+ История
+ Наука
+ Читалка
+ Последняя открытая книга всегда под рукой
+ Начните читать книгу
+ Скачайте книгу из каталога — она появится здесь и будет доступна офлайн.
+ Найти книгу
+ Продолжить
+ Скачано книг: %1$d
+ О книге
+ Информация
+ Язык: %1$s
+ Год: %1$s
+ Издатель: %1$s
+ Описание для этой книги пока не указано.
+ Скачать
+ Читать
+ Скачивание… %1$d%%
+ Книга скачана и доступна офлайн
+ Добавить в избранное
+ Убрать из избранного
+ Поделиться книгой
+ Для этой книги нет доступной ссылки.
diff --git a/app/src/main/res/values/strings_update.xml b/app/src/main/res/values/strings_update.xml
index 6cc73eb..e3f1855 100644
--- a/app/src/main/res/values/strings_update.xml
+++ b/app/src/main/res/values/strings_update.xml
@@ -3,10 +3,9 @@
Обновление приложения
Aletheia проверяет релизы в Argus и передаёт установку Android без выхода из настроек.
Установлена
- %1$s (%2$d)
+ %1$s
Доступный релиз
- %1$s (%2$s)
- %1$s
+ %1$s
%1$s • %2$s
Примечание к релизу: %1$s
Обновление ещё не проверялось
@@ -14,11 +13,9 @@
Проверяю Argus
Запрашиваю manifest релиза и сверяю его с установленной версией Aletheia.
Установлена актуальная версия
- Версия %1$s (%2$s) уже установлена.
- Версия %1$s уже установлена.
+ Версия %1$s уже установлена.
Доступно обновление
- Доступна версия %1$s (%2$s). Нажмите «%3$s», чтобы продолжить.
- Доступна версия %1$s. Нажмите «%2$s», чтобы продолжить.
+ Доступна версия %1$s. Нажмите «%2$s», чтобы продолжить.
Нужно разрешение Android
Android не разрешает установку APK из этого источника, пока вы не откроете системный экран и не разрешите установку для Aletheia.
Релиз не опубликован
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index fcb3bfd..a3a831d 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,8 +1,8 @@
-
@@ -29,8 +29,8 @@
@@ -96,12 +96,12 @@
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..97d821b
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index 326b046..e3506e3 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -7,6 +7,8 @@
127.0.0.1
10.0.2.2
192.168.0.185
+ m.flibusta.is
+ staticm.flibusta.is
local
diff --git a/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt b/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt
new file mode 100644
index 0000000..1501e9e
--- /dev/null
+++ b/app/src/test/java/com/aletheia/app/data/OpdsCatalogParserTest.kt
@@ -0,0 +1,117 @@
+package com.aletheia.app.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class OpdsCatalogParserTest {
+ @Test
+ fun parsesFlibustaEntryAndPrefersEpubAcquisition() {
+ val books = OpdsCatalogParser.parseBooks(SAMPLE_FEED) { href -> "http://m.flibusta.is$href" }
+
+ assertEquals(1, books.size)
+ val book = books.single()
+ assertEquals("tag:book:123", book.id)
+ assertEquals("Тестовая книга", book.title)
+ assertEquals("Первый Автор, Второй Автор", book.author)
+ assertEquals("epub", book.format)
+ assertEquals("http://m.flibusta.is/b/123/epub", book.downloadUrl)
+ assertEquals("http://m.flibusta.is/b/123", book.shareUrl)
+ assertEquals("http://m.flibusta.is/i/23/123/cover.jpg", book.coverUrl)
+ assertEquals("Описание книги Год издания: 2026", book.description)
+ assertEquals("ru", book.language)
+ assertEquals("2026", book.published)
+ }
+
+ @Test
+ fun ignoresNavigationEntriesWithoutSupportedBookDownload() {
+ val books = OpdsCatalogParser.parseBooks(NAVIGATION_FEED) { it }
+
+ assertTrue(books.isEmpty())
+ }
+
+ @Test
+ fun returnsResolvedNextPageEvenWhenSomeEntriesAreFilteredOut() {
+ val page = OpdsCatalogParser.parsePage(PAGINATED_FEED) { href -> "https://books.example$href" }
+
+ assertTrue(page.books.isEmpty())
+ assertEquals("https://books.example/opds/search?pageNumber=2", page.nextPageUrl)
+ }
+
+ @Test
+ fun recognizesFeedAndRejectsDoctype() {
+ assertTrue(OpdsCatalogParser.isOpdsFeed(NAVIGATION_FEED))
+ assertFalse(OpdsCatalogParser.isOpdsFeed(""))
+ }
+
+ @Test
+ fun removesSourceAttributionFromRemoteDescription() {
+ val feed = SAMPLE_FEED.replace(
+ "Описание книги",
+ "Описание книги Флибуста https://m.flibusta.is/opds"
+ )
+ val description = OpdsCatalogParser.parseBooks(feed) { it }.single().description.orEmpty()
+
+ assertFalse(description.contains("флибуста", ignoreCase = true))
+ assertFalse(description.contains("flibusta", ignoreCase = true))
+ assertFalse(description.contains("m.flibusta.is", ignoreCase = true))
+ }
+
+ @Test
+ fun rewritesFlibustaHttpPort443RedirectToHttps() {
+ val resolved = CatalogUrlResolver.resolveDownloadRedirect(
+ sourceUrl = "http://m.flibusta.is/b/123/epub",
+ location = "http://staticm.flibusta.is:443/converter/get/convert?out=epub&md5=abc"
+ )
+
+ assertEquals(
+ "https://staticm.flibusta.is/converter/get/convert?out=epub&md5=abc",
+ resolved
+ )
+ }
+
+ private companion object {
+ private val SAMPLE_FEED = """
+
+
+
+ tag:book:123
+ Тестовая книга
+ Первый Автор
+ Второй Автор
+ Описание книги<br/>Год издания: 2026
+ ru
+ 2026
+
+
+
+
+
+
+ """.trimIndent()
+
+ private val NAVIGATION_FEED = """
+
+
+
+ tag:root:new
+ Новинки
+
+
+
+ """.trimIndent()
+
+ private val PAGINATED_FEED = """
+
+
+
+
+ tag:root:new
+ Навигация
+
+
+
+ """.trimIndent()
+ }
+}
diff --git a/app/src/test/java/com/aletheia/app/data/QBooksUrlPolicyTest.kt b/app/src/test/java/com/aletheia/app/data/QBooksUrlPolicyTest.kt
index 1b98d8a..a13bfe9 100644
--- a/app/src/test/java/com/aletheia/app/data/QBooksUrlPolicyTest.kt
+++ b/app/src/test/java/com/aletheia/app/data/QBooksUrlPolicyTest.kt
@@ -8,13 +8,22 @@ import org.junit.Test
class QBooksUrlPolicyTest {
@Test
fun allowsExternalHttps() {
- val validation = QBooksUrlPolicy.validate("https://qbooks.kusoft.xyz/")
+ val validation = QBooksUrlPolicy.validate("https://books.example/")
assertTrue(validation.isAllowed)
- assertEquals("https://qbooks.kusoft.xyz", validation.normalizedUrl)
+ assertEquals("https://books.example", validation.normalizedUrl)
assertEquals("https", validation.scheme)
}
+ @Test
+ fun allowsBundledCatalogOverHttp() {
+ val validation = QBooksUrlPolicy.validate("http://m.flibusta.is/opds/")
+
+ assertTrue(validation.isAllowed)
+ assertEquals("http://m.flibusta.is/opds", validation.normalizedUrl)
+ assertEquals("http", validation.scheme)
+ }
+
@Test
fun allowsExplicitLocalHttpHosts() {
val localUrls = listOf(
@@ -34,7 +43,7 @@ class QBooksUrlPolicyTest {
@Test
fun rejectsExternalHttp() {
- val validation = QBooksUrlPolicy.validate("http://qbooks.kusoft.xyz")
+ val validation = QBooksUrlPolicy.validate("http://books.example")
assertFalse(validation.isAllowed)
assertEquals(QBooksUrlPolicy.Reason.ExternalCleartext, validation.reason)
@@ -42,7 +51,7 @@ class QBooksUrlPolicyTest {
@Test
fun rejectsUnsupportedSchemes() {
- val validation = QBooksUrlPolicy.validate("ftp://qbooks.kusoft.xyz")
+ val validation = QBooksUrlPolicy.validate("ftp://books.example")
assertFalse(validation.isAllowed)
assertEquals(QBooksUrlPolicy.Reason.UnsupportedScheme, validation.reason)
diff --git a/app/src/test/java/com/aletheia/app/data/SettingsRepositoryTest.kt b/app/src/test/java/com/aletheia/app/data/SettingsRepositoryTest.kt
new file mode 100644
index 0000000..dc4d500
--- /dev/null
+++ b/app/src/test/java/com/aletheia/app/data/SettingsRepositoryTest.kt
@@ -0,0 +1,28 @@
+package com.aletheia.app.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SettingsRepositoryTest {
+ @Test
+ fun usesFlibustaForEmptyAndLegacyQBooksAddresses() {
+ assertEquals(SettingsRepository.DEFAULT_CATALOG_URL, SettingsRepository.resolveCatalogUrl(null))
+ assertEquals(SettingsRepository.DEFAULT_CATALOG_URL, SettingsRepository.resolveCatalogUrl(""))
+ assertEquals(
+ SettingsRepository.DEFAULT_CATALOG_URL,
+ SettingsRepository.resolveCatalogUrl("https://qbooks.kusoft.xyz/")
+ )
+ assertEquals(
+ SettingsRepository.DEFAULT_CATALOG_URL,
+ SettingsRepository.resolveCatalogUrl("https://m.flibusta.is/opds/")
+ )
+ }
+
+ @Test
+ fun preservesCustomCatalogAddress() {
+ assertEquals(
+ "https://books.example/opds",
+ SettingsRepository.resolveCatalogUrl(" https://books.example/opds/ ")
+ )
+ }
+}
diff --git a/app/src/test/java/com/aletheia/app/ui/reader/ReaderEventTest.kt b/app/src/test/java/com/aletheia/app/ui/reader/ReaderEventTest.kt
new file mode 100644
index 0000000..cce2a87
--- /dev/null
+++ b/app/src/test/java/com/aletheia/app/ui/reader/ReaderEventTest.kt
@@ -0,0 +1,179 @@
+package com.aletheia.app.ui.reader
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderEventTest {
+ @Test
+ fun progressPreservesStructuredLocator() {
+ val event = ReaderEvent.parse(
+ """
+ {
+ "version": 2,
+ "type": "progress",
+ "payload": {
+ "progress": 0.42,
+ "locator": {"type":"epub","cfi":"epubcfi(/6/2)"},
+ "chapter": "Глава 2",
+ "currentPage": 12,
+ "totalPages": 30,
+ "chapterCurrentPage": 2,
+ "chapterTotalPages": 7
+ }
+ }
+ """.trimIndent()
+ ) as ReaderEvent.Progress
+
+ assertEquals(0.42, event.progress, 0.0001)
+ assertEquals("Глава 2", event.chapter)
+ assertTrue(requireNotNull(event.locator).contains("epubcfi(/6/2)"))
+ assertEquals(12, event.currentPage)
+ assertEquals(7, event.chapterTotalPages)
+ }
+
+ @Test
+ fun tocReadsV2ItemsArray() {
+ val event = ReaderEvent.parse(
+ """
+ {
+ "version": 2,
+ "type": "toc",
+ "payload": {
+ "items": [
+ {"label":"Глава 1","href":"chapter-1","depth":0},
+ {"label":"Часть 1","href":"part-1","depth":1}
+ ]
+ }
+ }
+ """.trimIndent()
+ ) as ReaderEvent.Toc
+
+ assertEquals(2, event.chapters.size)
+ assertEquals("Часть 1", event.chapters[1].label)
+ assertEquals(1, event.chapters[1].depth)
+ }
+
+ @Test
+ fun progressAcceptsEngineChapterPageAliases() {
+ val event = ReaderEvent.parse(
+ """
+ {
+ "version": 2,
+ "type": "progress",
+ "payload": {
+ "progress": 0.5,
+ "chapterPage": 4,
+ "chapterTotal": 11
+ }
+ }
+ """.trimIndent()
+ ) as ReaderEvent.Progress
+
+ assertEquals(4, event.chapterCurrentPage)
+ assertEquals(11, event.chapterTotalPages)
+ }
+
+ @Test
+ fun errorPreservesRecoveryMetadata() {
+ val event = ReaderEvent.parse(
+ """
+ {
+ "version": 2,
+ "type": "error",
+ "payload": {
+ "stage": "epub.navigation",
+ "code": "DisplayError",
+ "message": "Не удалось перейти",
+ "recoverable": false
+ }
+ }
+ """.trimIndent()
+ ) as ReaderEvent.Error
+
+ assertEquals("epub.navigation", event.stage)
+ assertEquals("DisplayError", event.code)
+ assertEquals("Не удалось перейти", event.message)
+ assertEquals(false, event.recoverable)
+ }
+
+ @Test
+ fun preferencesUseReaderV2Schema() {
+ val json = ReaderPreferences(
+ fontName = "PT Serif",
+ fontSize = 24,
+ lineHeight = 1.7,
+ margin = 20,
+ verticalScroll = true,
+ invertZones = true
+ ).toEngineJson()
+
+ assertEquals(24, json.getInt("fontSize"))
+ assertEquals(1.7, json.getDouble("lineHeight"), 0.0001)
+ assertTrue(json.getString("fontFamily").contains("PT Serif"))
+ assertTrue(json.getBoolean("verticalScroll"))
+ assertTrue(json.getBoolean("invertZones"))
+ }
+
+ @Test
+ fun preferencesClampValuesToReaderV2Contract() {
+ val json = ReaderPreferences(
+ fontSize = 100,
+ lineHeight = 0.5,
+ margin = 2
+ ).toEngineJson()
+
+ assertEquals(ReaderPreferences.MAX_FONT_SIZE, json.getInt("fontSize"))
+ assertEquals(ReaderPreferences.MIN_LINE_HEIGHT, json.getDouble("lineHeight"), 0.0001)
+ assertEquals(ReaderPreferences.MIN_MARGIN, json.getInt("margin"))
+ }
+
+ @Test
+ fun preferencesStateRoundTripPreservesNativeOptions() {
+ val source = ReaderPreferences(
+ fontName = "Merriweather",
+ fontSize = 26,
+ lineHeight = 1.8,
+ margin = 32,
+ theme = ReaderPreferences.THEME_DARK,
+ verticalScroll = true,
+ brightness = 63,
+ orientation = ReaderPreferences.ORIENTATION_LANDSCAPE,
+ keepScreenOn = true,
+ showTitle = false,
+ showStatus = true
+ )
+
+ assertEquals(source, ReaderPreferences.fromStateJson(source.toStateJson()))
+ }
+
+ @Test
+ fun nativeOnlyPreferencesDoNotInvalidateWebEngine() {
+ val source = ReaderPreferences()
+ val nativeOnly = source.copy(
+ brightness = 42,
+ systemBrightness = true,
+ orientation = ReaderPreferences.ORIENTATION_LANDSCAPE,
+ volumeButtons = false,
+ keepScreenOn = true,
+ showTitle = false,
+ showStatus = true
+ )
+
+ assertTrue(source.hasSameEnginePreferences(nativeOnly))
+ assertFalse(source.hasSameEnginePreferences(source.copy(pageTurnMode = ReaderPreferences.PAGE_TURN_SWIPE)))
+ }
+
+ @Test
+ fun locatorValidationRejectsWrongFormatAndKeepsLegacyFb2() {
+ val epubLocator = """{"type":"epub","cfi":"epubcfi(/6/2)"}"""
+ val fb2Locator = """{"type":"fb2","sectionId":"fb2-section-2","offset":17}"""
+ val legacyFb2Locator = "fb2:fb2-section-2:17:23"
+
+ assertEquals(null, ReaderWebController.validatedLocator("fb2", epubLocator))
+ assertEquals(null, ReaderWebController.validatedLocator("epub", fb2Locator))
+ assertTrue(requireNotNull(ReaderWebController.validatedLocator("fb2", fb2Locator)).contains("fb2-section-2"))
+ assertEquals(legacyFb2Locator, ReaderWebController.validatedLocator("fb2", legacyFb2Locator))
+ }
+}
diff --git a/app/src/test/java/com/aletheia/app/ui/reader/ReaderStateCoordinatorTest.kt b/app/src/test/java/com/aletheia/app/ui/reader/ReaderStateCoordinatorTest.kt
new file mode 100644
index 0000000..82b3808
--- /dev/null
+++ b/app/src/test/java/com/aletheia/app/ui/reader/ReaderStateCoordinatorTest.kt
@@ -0,0 +1,31 @@
+package com.aletheia.app.ui.reader
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReaderStateCoordinatorTest {
+ @Test
+ fun latestPublicationWinsAcrossReaderInstances() {
+ val coordinator = ReaderStateCoordinator()
+ val first = coordinator.publish(7L, 0.2, "first", null, 2, 10, 1, 3)
+ val second = coordinator.publish(7L, 0.8, "second", "Глава", 8, 10, 2, 3)
+
+ assertTrue(second.revision > first.revision)
+ assertEquals("second", coordinator.latest(7L)?.locator)
+ assertEquals(0.8, coordinator.latest(7L)?.progress ?: 0.0, 0.0001)
+ }
+
+ @Test
+ fun clearDropsOnlyRequestedBook() {
+ val coordinator = ReaderStateCoordinator()
+ coordinator.publish(1L, 0.1, null, null, 1, 10, 1, 1)
+ coordinator.publish(2L, 0.2, null, null, 2, 10, 1, 1)
+
+ coordinator.clear(1L)
+
+ assertNull(coordinator.latest(1L))
+ assertEquals(2L, coordinator.latest(2L)?.bookId)
+ }
+}
diff --git a/app/src/test/java/xyz/kusoft/argusupdater/ArgusUpdateCacheTest.kt b/app/src/test/java/xyz/kusoft/argusupdater/ArgusUpdateCacheTest.kt
new file mode 100644
index 0000000..640d3f0
--- /dev/null
+++ b/app/src/test/java/xyz/kusoft/argusupdater/ArgusUpdateCacheTest.kt
@@ -0,0 +1,50 @@
+package xyz.kusoft.argusupdater
+
+import java.io.File
+import java.nio.file.Files
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ArgusUpdateCacheTest {
+ @Test
+ fun prepareTargetRemovesFilesFromPreviousUpdates() {
+ withTemporaryCache { cacheDirectory, cache ->
+ File(cacheDirectory, "old-release.apk").apply {
+ parentFile?.mkdirs()
+ writeText("old")
+ }
+ File(cacheDirectory, "new-release.apk.part").writeText("partial")
+
+ val target = cache.prepareTarget()
+
+ assertEquals(ArgusUpdateCache.PACKAGE_FILE_NAME, target.name)
+ assertTrue(requireNotNull(target.parentFile).isDirectory)
+ assertFalse(File(cacheDirectory, "old-release.apk").exists())
+ assertFalse(File(cacheDirectory, "new-release.apk.part").exists())
+ }
+ }
+
+ @Test
+ fun clearRemovesDownloadedAndPartialApks() {
+ withTemporaryCache { cacheDirectory, cache ->
+ cache.prepareTarget().writeText("downloaded")
+ File(cacheDirectory, "${ArgusUpdateCache.PACKAGE_FILE_NAME}.part").writeText("partial")
+
+ cache.clear()
+
+ assertFalse(cacheDirectory.exists())
+ }
+ }
+
+ private fun withTemporaryCache(block: (File, ArgusUpdateCache) -> Unit) {
+ val root = Files.createTempDirectory("aletheia-argus-cache-test").toFile()
+ try {
+ val cacheDirectory = File(root, ArgusUpdateCache.DIRECTORY_NAME)
+ block(cacheDirectory, ArgusUpdateCache(cacheDirectory))
+ } finally {
+ root.deleteRecursively()
+ }
+ }
+}
diff --git a/scripts/prepare-argus-package.ps1 b/scripts/prepare-argus-package.ps1
index 1f63e66..2b60061 100644
--- a/scripts/prepare-argus-package.ps1
+++ b/scripts/prepare-argus-package.ps1
@@ -171,14 +171,10 @@ function Get-ArgusTemplate {
function Resolve-PackageFileName {
param(
[string]$Version,
- [object]$Template
+ [long]$VersionCode
)
- if ($Template -and $Template.packageFile -and ($Template.packageFile -match "^(?.+)-\d[\w\.\-]*(?\.apk)$")) {
- return "$($matches.base)-$Version$($matches.ext)"
- }
-
- return "aletheia-$Version.apk"
+ return "aletheia-$Version-$VersionCode.apk"
}
function Resolve-KeystoreInfoPath {
@@ -296,6 +292,10 @@ $versionName = [string]$releaseElement.versionName
if ([string]::IsNullOrWhiteSpace($versionName)) {
throw "versionName is empty in release output metadata."
}
+$versionCode = [long]$releaseElement.versionCode
+if ($versionCode -le 0) {
+ throw "versionCode must be positive in release output metadata."
+}
$unsignedApk = Join-Path $projectRoot ("app\build\outputs\apk\release\" + $releaseElement.outputFile)
if (-not (Test-Path -LiteralPath $unsignedApk)) {
@@ -312,7 +312,7 @@ $keystorePath = Resolve-KeystorePath -ConfiguredPath $keystoreInfo["KeystorePath
$template = Get-ArgusTemplate -ProjectRoot $projectRoot
$slug = if ($template -and $template.slug) { [string]$template.slug } else { "aletheia-kotlin" }
-$packageFile = Resolve-PackageFileName -Version $versionName -Template $template
+$packageFile = Resolve-PackageFileName -Version $versionName -VersionCode $versionCode
$outputDir = Join-Path $projectRoot ("artifacts\argus\$slug\$versionName")
$alignedApk = Join-Path $outputDir ([IO.Path]::GetFileNameWithoutExtension($packageFile) + "-aligned.apk")
$signedApk = Join-Path $outputDir $packageFile
@@ -413,7 +413,9 @@ $manifest = [ordered]@{
platform = if ($template -and $template.platform) { $template.platform } else { "android" }
packageKind = if ($template -and $template.packageKind) { $template.packageKind } else { "apk" }
releaseNotes = $releaseNotesText
- publicCatalog = if ($template -and $null -ne $template.publicCatalog) { [bool]$template.publicCatalog } else { $false }
+ # The updater reads the public manifest endpoint. An unlisted app returns 404 there,
+ # so Aletheia releases must stay visible to the public Argus API.
+ publicCatalog = $true
packageFile = $packageFile
packageSha256 = $packageSha256
signerInfo = ($signerInfo.TrimEnd() -replace "\r?\n", [Environment]::NewLine)
diff --git a/scripts/publish-argus-release.ps1 b/scripts/publish-argus-release.ps1
index 4e22406..bcf6753 100644
--- a/scripts/publish-argus-release.ps1
+++ b/scripts/publish-argus-release.ps1
@@ -1,8 +1,8 @@
param(
[string]$ManifestPath,
- [string]$SshHost = "192.168.0.185",
+ [string]$SshHost = "192.168.0.25",
[string]$SshUser = "sevenhill",
- [string]$ArgusDataPath = "/srv/argus-data",
+ [string]$ArgusDataPath = "/opt/argus/data",
[switch]$RestartArgus
)
@@ -309,6 +309,7 @@ $remoteScriptLines = @(
"export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)",
"export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)",
"export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)",
+ 'trap ''rm -f -- "$SOURCE_FILE"'' EXIT',
'python3 - "$SOURCE_FILE" <<''PY''',
$pythonBlock,
"PY",
diff --git a/tools/tts/GPU_TRAINING.md b/tools/tts/GPU_TRAINING.md
new file mode 100644
index 0000000..9e421e7
--- /dev/null
+++ b/tools/tts/GPU_TRAINING.md
@@ -0,0 +1,51 @@
+# Aletheia Russian Piper/VITS training
+
+The selected deployment format is a single-speaker Piper/VITS model exported to ONNX. Piper's current official
+training interface consumes `wav-file|text` CSV rows, supports Russian through the `ru` espeak-ng voice, and
+exports a checkpoint with `python3 -m piper.train.export_onnx`.
+
+## GPU host prerequisites
+
+- Windows or Linux with an NVIDIA CUDA GPU and a working `nvidia-smi`.
+- Git, Python 3, build-essential, CMake, and Ninja.
+- A checkout of `https://github.com/OHF-Voice/piper1-gpl` with the `[train]` dependencies installed and
+ `build_monotonic_align.sh` completed.
+- The generated Aletheia dataset directory containing `metadata.csv` and `wav/`.
+
+## Scratch training
+
+```bash
+git clone https://github.com/OHF-Voice/piper1-gpl.git
+cd piper1-gpl
+python3 -m venv .venv
+source .venv/bin/activate
+python3 -m pip install -e '.[train]'
+./build_monotonic_align.sh
+python3 setup.py build_ext --inplace
+
+/path/to/Aletheia/tools/tts/train_piper.sh \
+ /path/to/tts-dataset \
+ /path/to/tts-training \
+ "$PWD" \
+ 2000
+```
+
+No `--ckpt_path` is passed: the Aletheia acoustic model is initialized from scratch. This is intentionally slower
+than Piper's recommended checkpoint fine-tuning. The exported deliverables are `aletheia_ru.onnx`,
+`aletheia_ru.onnx.json`, and `artifacts.json` with byte counts and SHA-256 hashes.
+
+Before accepting the model, synthesize a held-out Russian validation list at multiple `length_scale`,
+`noise_scale`, and `noise_scale_w` settings, measure real-time factor on the target phone, and listen for skipped
+words, unstable stress, clicks, and repeated phonemes. A successful export alone is not a quality gate.
+
+On native Windows, first run the one-batch CUDA check:
+
+```powershell
+.\train_piper_windows.ps1 `
+ -DatasetDir C:\path\to\tts-dataset `
+ -OutputDir C:\path\to\tts-smoke `
+ -PythonExe C:\path\to\.venv\Scripts\python.exe `
+ -SmokeTest
+```
+
+The default Windows batch size is 4 so the smoke test can establish actual memory use before increasing it.
diff --git a/tools/tts/README.md b/tools/tts/README.md
new file mode 100644
index 0000000..8877f98
--- /dev/null
+++ b/tools/tts/README.md
@@ -0,0 +1,35 @@
+# Aletheia Russian TTS corpus tools
+
+`audit_corpus.py` verifies that an audiobook and EPUB contain the same text before any training data is produced.
+It extracts the EPUB spine in reading order and compares local Whisper transcripts from the beginning, middle,
+and end of the audiobook with the normalized book text.
+
+Generated audio, transcripts, downloaded ASR models, and future training checkpoints belong under
+`.codex-temp/tts-*`; they are working artifacts and must not be committed.
+
+Example on Windows PowerShell:
+
+```powershell
+$env:PYTHONPATH = 'C:\Repos\Aletheia\.codex-temp\tts-python'
+python tools\tts\audit_corpus.py `
+ --source-dir 'C:\path\to\audiobook' `
+ --output-dir '.codex-temp\tts-audit' `
+ --model base
+```
+
+The audit is not a copyright or voice-consent check. Training and distributing a voice model requires
+separate confirmation that the recordings and narrator's voice may be used for that purpose.
+
+After the audit passes, `transcribe_corpus.py` creates one resumable JSON sidecar per MP3 with segment and
+word timestamps. Existing sidecars are skipped unless `--force` is supplied. These transcripts are alignment
+anchors only; the final Piper labels must come from the exact EPUB text.
+
+`build_piper_dataset.py` globally aligns all ASR tokens to the EPUB in monotonic reading order, rejects weak
+matches, merges adjacent short segments, and writes 22.05 kHz mono PCM WAV files plus Piper's
+`filename.wav|Exact book text` metadata. Use `--dry-run` first and inspect the reported exact-token match ratio
+before producing WAV files.
+
+`train_piper.sh` is the CUDA/Linux entry point for scratch training and ONNX export.
+`train_piper_windows.ps1` provides the equivalent native Windows path and a `-SmokeTest` mode that executes one
+training and validation batch on CUDA before a long run. See `GPU_TRAINING.md` for host prerequisites and the
+acceptance gate.
diff --git a/tools/tts/audit_corpus.py b/tools/tts/audit_corpus.py
new file mode 100644
index 0000000..a1d67dc
--- /dev/null
+++ b/tools/tts/audit_corpus.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""Audit an audiobook/EPUB pair before building a single-speaker TTS corpus."""
+
+from __future__ import annotations
+
+import argparse
+import collections
+import difflib
+import json
+import re
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path, PurePosixPath
+
+
+WORD_RE = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE)
+WHITESPACE_RE = re.compile(r"\s+")
+
+
+def normalized_words(text: str) -> list[str]:
+ return [word.replace("ё", "е") for word in WORD_RE.findall(text.lower())]
+
+
+def element_text(root: ET.Element) -> str:
+ ignored = {"script", "style", "svg", "math"}
+ parts: list[str] = []
+
+ def visit(node: ET.Element) -> None:
+ tag = node.tag.rsplit("}", 1)[-1].lower()
+ if tag in ignored:
+ return
+ if node.text:
+ parts.append(node.text)
+ for child in node:
+ visit(child)
+ if child.tail:
+ parts.append(child.tail)
+ if tag in {"p", "div", "section", "h1", "h2", "h3", "h4", "h5", "h6", "li", "br"}:
+ parts.append("\n")
+
+ visit(root)
+ lines = [WHITESPACE_RE.sub(" ", line).strip() for line in "".join(parts).splitlines()]
+ return "\n".join(line for line in lines if line)
+
+
+def extract_epub(epub_path: Path, output_dir: Path) -> tuple[str, list[dict[str, object]]]:
+ with zipfile.ZipFile(epub_path) as archive:
+ container = ET.fromstring(archive.read("META-INF/container.xml"))
+ rootfile = next(
+ node.attrib["full-path"]
+ for node in container.iter()
+ if node.tag.rsplit("}", 1)[-1] == "rootfile"
+ )
+ opf = ET.fromstring(archive.read(rootfile))
+ opf_dir = PurePosixPath(rootfile).parent
+ manifest = {
+ node.attrib["id"]: node.attrib["href"]
+ for node in opf.iter()
+ if node.tag.rsplit("}", 1)[-1] == "item" and "id" in node.attrib and "href" in node.attrib
+ }
+ spine_ids = [
+ node.attrib["idref"]
+ for node in opf.iter()
+ if node.tag.rsplit("}", 1)[-1] == "itemref" and "idref" in node.attrib
+ ]
+ sections: list[dict[str, object]] = []
+ texts: list[str] = []
+ for index, item_id in enumerate(spine_ids):
+ href = manifest.get(item_id)
+ if not href:
+ continue
+ member = str(opf_dir / PurePosixPath(href))
+ try:
+ root = ET.fromstring(archive.read(member))
+ except (KeyError, ET.ParseError):
+ continue
+ text = element_text(root)
+ if not text:
+ continue
+ headings = [
+ WHITESPACE_RE.sub(" ", "".join(node.itertext())).strip()
+ for node in root.iter()
+ if node.tag.rsplit("}", 1)[-1].lower() in {"h1", "h2", "h3"}
+ ]
+ sections.append(
+ {
+ "index": index,
+ "href": member,
+ "characters": len(text),
+ "words": len(normalized_words(text)),
+ "headings": [heading for heading in headings if heading],
+ }
+ )
+ texts.append(text)
+
+ book_text = "\n\n".join(texts)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ (output_dir / "book.txt").write_text(book_text, encoding="utf-8")
+ (output_dir / "spine.json").write_text(
+ json.dumps(sections, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ return book_text, sections
+
+
+def best_text_match(transcript: str, book_words: list[str]) -> dict[str, object]:
+ spoken = normalized_words(transcript)
+ if not spoken or not book_words:
+ return {"ratio": 0.0, "book_excerpt": "", "word_offset": None}
+ positions: dict[str, list[int]] = collections.defaultdict(list)
+ for index, word in enumerate(book_words):
+ if len(word) >= 4:
+ positions[word].append(index)
+ votes: collections.Counter[int] = collections.Counter()
+ for spoken_index, word in enumerate(spoken):
+ candidates = positions.get(word, ())
+ if len(candidates) <= 200:
+ votes.update(book_index - spoken_index for book_index in candidates)
+ offsets = [offset for offset, _ in votes.most_common(30)] or [0]
+ best_ratio = 0.0
+ best_offset = 0
+ best_window: list[str] = []
+ for offset in offsets:
+ start = max(0, offset - 8)
+ window = book_words[start : start + len(spoken) + 16]
+ ratio = difflib.SequenceMatcher(None, spoken, window, autojunk=False).ratio()
+ if ratio > best_ratio:
+ best_ratio, best_offset, best_window = ratio, start, window
+ return {
+ "ratio": round(best_ratio, 4),
+ "book_excerpt": " ".join(best_window),
+ "word_offset": best_offset,
+ }
+
+
+def transcribe_samples(
+ audio_files: list[Path],
+ book_text: str,
+ output_dir: Path,
+ model_name: str,
+ sample_seconds: int,
+) -> list[dict[str, object]]:
+ try:
+ import av
+ import imageio_ffmpeg
+ from faster_whisper import WhisperModel
+ except ImportError as error:
+ raise SystemExit(
+ "Install audit dependencies into PYTHONPATH: faster-whisper imageio-ffmpeg"
+ ) from error
+
+ chosen = [audio_files[0], audio_files[len(audio_files) // 2], audio_files[-1]]
+ samples_dir = output_dir / "samples"
+ samples_dir.mkdir(parents=True, exist_ok=True)
+ ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
+ model = WhisperModel(
+ model_name,
+ device="cpu",
+ compute_type="int8",
+ download_root=str(output_dir / "models"),
+ )
+ book_words = normalized_words(book_text)
+ results: list[dict[str, object]] = []
+ for source in chosen:
+ with av.open(str(source)) as media:
+ stream = media.streams.audio[0]
+ duration = float(stream.duration * stream.time_base) if stream.duration is not None else 0.0
+ start = min(max(45.0, duration * 0.35), max(0.0, duration - sample_seconds - 5.0))
+ wav_path = samples_dir / f"{source.stem[:4]}-{int(start):05d}.wav"
+ subprocess.run(
+ [
+ ffmpeg,
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-y",
+ "-ss",
+ f"{start:.3f}",
+ "-i",
+ str(source),
+ "-t",
+ str(sample_seconds),
+ "-ac",
+ "1",
+ "-ar",
+ "16000",
+ str(wav_path),
+ ],
+ check=True,
+ )
+ segments, info = model.transcribe(
+ str(wav_path),
+ language="ru",
+ beam_size=5,
+ vad_filter=True,
+ condition_on_previous_text=True,
+ )
+ transcript = " ".join(segment.text.strip() for segment in segments).strip()
+ result = {
+ "source": source.name,
+ "source_duration_seconds": round(duration, 3),
+ "sample_start_seconds": round(start, 3),
+ "sample_duration_seconds": sample_seconds,
+ "detected_language": info.language,
+ "language_probability": round(info.language_probability, 4),
+ "transcript": transcript,
+ }
+ result.update(best_text_match(transcript, book_words))
+ results.append(result)
+ (output_dir / "alignment_samples.json").write_text(
+ json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ return results
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--source-dir", type=Path, required=True)
+ parser.add_argument("--epub", type=Path)
+ parser.add_argument("--output-dir", type=Path, required=True)
+ parser.add_argument("--model", default="base")
+ parser.add_argument("--sample-seconds", type=int, default=75)
+ parser.add_argument("--skip-asr", action="store_true")
+ args = parser.parse_args()
+
+ audio_files = sorted(args.source_dir.glob("*.mp3"))
+ epub_path = args.epub or next(args.source_dir.glob("*.epub"), None)
+ if not audio_files:
+ parser.error("No MP3 files found")
+ if epub_path is None or not epub_path.is_file():
+ parser.error("EPUB file not found")
+
+ book_text, sections = extract_epub(epub_path, args.output_dir)
+ report: dict[str, object] = {
+ "source_dir": str(args.source_dir.resolve()),
+ "epub": str(epub_path.resolve()),
+ "audio_files": [str(path.resolve()) for path in audio_files],
+ "audio_file_count": len(audio_files),
+ "epub_section_count": len(sections),
+ "book_characters": len(book_text),
+ "book_words": len(normalized_words(book_text)),
+ }
+ if not args.skip_asr:
+ report["alignment_samples"] = transcribe_samples(
+ audio_files, book_text, args.output_dir, args.model, args.sample_seconds
+ )
+ (args.output_dir / "audit.json").write_text(
+ json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/tts/build_piper_dataset.py b/tools/tts/build_piper_dataset.py
new file mode 100644
index 0000000..e5a8fcb
--- /dev/null
+++ b/tools/tts/build_piper_dataset.py
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+"""Align ASR sidecars to exact EPUB text and create a Piper WAV/metadata dataset."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import difflib
+import json
+import re
+import subprocess
+import sys
+import wave
+from dataclasses import dataclass
+from pathlib import Path
+
+
+WORD_RE = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE)
+
+
+def normalize_word(value: str) -> str:
+ return value.lower().replace("ё", "е")
+
+
+@dataclass(frozen=True)
+class BookWord:
+ value: str
+ start: int
+ end: int
+
+
+@dataclass(frozen=True)
+class AsrToken:
+ value: str
+ track: str
+ segment_id: int
+ start: float
+ end: float
+
+
+@dataclass
+class AlignedSegment:
+ track: str
+ source: Path
+ start: float
+ end: float
+ book_start: int
+ book_end: int
+ matched_tokens: int
+ total_tokens: int
+ avg_logprob: float
+ no_speech_prob: float
+
+ @property
+ def duration(self) -> float:
+ return self.end - self.start
+
+ @property
+ def coverage(self) -> float:
+ return self.matched_tokens / max(1, self.total_tokens)
+
+
+def load_book(path: Path) -> tuple[str, list[BookWord]]:
+ text = path.read_text(encoding="utf-8")
+ words = [
+ BookWord(normalize_word(match.group()), match.start(), match.end())
+ for match in WORD_RE.finditer(text)
+ ]
+ return text, words
+
+
+def load_asr(sidecars: list[Path]) -> tuple[list[AsrToken], list[dict[str, object]], dict[str, Path]]:
+ tokens: list[AsrToken] = []
+ segments: list[dict[str, object]] = []
+ sources: dict[str, Path] = {}
+ for sidecar in sidecars:
+ payload = json.loads(sidecar.read_text(encoding="utf-8"))
+ track = sidecar.stem
+ source = Path(payload["source"])
+ sources[track] = source
+ for segment in payload["segments"]:
+ token_start = len(tokens)
+ for word in segment.get("words", []):
+ normalized = [normalize_word(match.group()) for match in WORD_RE.finditer(word["word"])]
+ for value in normalized:
+ tokens.append(
+ AsrToken(
+ value=value,
+ track=track,
+ segment_id=int(segment["id"]),
+ start=float(word["start"]),
+ end=float(word["end"]),
+ )
+ )
+ segments.append(
+ {
+ "track": track,
+ "source": source,
+ "segment_id": int(segment["id"]),
+ "start": float(segment["start"]),
+ "end": float(segment["end"]),
+ "avg_logprob": float(segment["avg_logprob"]),
+ "no_speech_prob": float(segment["no_speech_prob"]),
+ "token_start": token_start,
+ "token_end": len(tokens),
+ }
+ )
+ return tokens, segments, sources
+
+
+def token_mapping(asr_tokens: list[AsrToken], book_words: list[BookWord]) -> dict[int, int]:
+ matcher = difflib.SequenceMatcher(
+ None,
+ [token.value for token in asr_tokens],
+ [word.value for word in book_words],
+ autojunk=True,
+ )
+ mapping: dict[int, int] = {}
+ for block in matcher.get_matching_blocks():
+ for offset in range(block.size):
+ mapping[block.a + offset] = block.b + offset
+ return mapping
+
+
+def align_segments(
+ segments: list[dict[str, object]],
+ mapping: dict[int, int],
+ book_words: list[BookWord],
+) -> list[AlignedSegment]:
+ aligned: list[AlignedSegment] = []
+ previous_book_end = -1
+ for segment in segments:
+ start_index = int(segment["token_start"])
+ end_index = int(segment["token_end"])
+ mapped = [mapping[index] for index in range(start_index, end_index) if index in mapping]
+ if not mapped:
+ continue
+ first, last = min(mapped), max(mapped)
+ if first < previous_book_end:
+ continue
+ asr_count = max(1, end_index - start_index)
+ book_count = last - first + 1
+ if book_count > asr_count * 1.8 + 8:
+ continue
+ item = AlignedSegment(
+ track=str(segment["track"]),
+ source=Path(segment["source"]),
+ start=float(segment["start"]),
+ end=float(segment["end"]),
+ book_start=book_words[first].start,
+ book_end=book_words[last].end,
+ matched_tokens=len(mapped),
+ total_tokens=asr_count,
+ avg_logprob=float(segment["avg_logprob"]),
+ no_speech_prob=float(segment["no_speech_prob"]),
+ )
+ aligned.append(item)
+ previous_book_end = last
+ return aligned
+
+
+def merge_segments(items: list[AlignedSegment], book_text: str, max_duration: float) -> list[AlignedSegment]:
+ merged: list[AlignedSegment] = []
+ current: AlignedSegment | None = None
+ for item in items:
+ eligible = item.coverage >= 0.55 and item.avg_logprob >= -1.2 and item.no_speech_prob <= 0.5
+ if not eligible or item.duration <= 0:
+ if current is not None:
+ merged.append(current)
+ current = None
+ continue
+ if current is None:
+ current = item
+ continue
+ gap = item.start - current.end
+ combined_duration = item.end - current.start
+ same_track = item.track == current.track and item.source == current.source
+ near_in_book = 0 <= item.book_start - current.book_end <= 120
+ if same_track and gap <= 0.8 and near_in_book and combined_duration <= max_duration:
+ total = current.total_tokens + item.total_tokens
+ current.end = item.end
+ current.book_end = item.book_end
+ current.matched_tokens += item.matched_tokens
+ current.avg_logprob = (
+ current.avg_logprob * current.total_tokens + item.avg_logprob * item.total_tokens
+ ) / total
+ current.no_speech_prob = max(current.no_speech_prob, item.no_speech_prob)
+ current.total_tokens = total
+ text = book_text[current.book_start : current.book_end].rstrip()
+ if current.duration >= 3.0 and text.endswith((".", "!", "?", "…", ":", ";")):
+ merged.append(current)
+ current = None
+ else:
+ merged.append(current)
+ current = item
+ if current is not None:
+ merged.append(current)
+ return merged
+
+
+def clean_label(value: str) -> str:
+ return re.sub(r"\s+", " ", value).strip(" —–-\t\r\n")
+
+
+def is_valid_wav(path: Path, sample_rate: int) -> bool:
+ if not path.is_file():
+ return False
+ try:
+ with wave.open(str(path), "rb") as audio:
+ return (
+ audio.getnchannels() == 1
+ and audio.getsampwidth() == 2
+ and audio.getframerate() == sample_rate
+ and audio.getnframes() > 0
+ )
+ except (EOFError, wave.Error):
+ return False
+
+
+def create_dataset(
+ items: list[AlignedSegment],
+ book_text: str,
+ output_dir: Path,
+ ffmpeg: str,
+ sample_rate: int,
+ min_duration: float,
+ max_duration: float,
+ min_coverage: float,
+) -> dict[str, object]:
+ wav_dir = output_dir / "wav"
+ wav_dir.mkdir(parents=True, exist_ok=True)
+ metadata_path = output_dir / "metadata.csv"
+ rows: list[tuple[str, str]] = []
+ accepted_seconds = 0.0
+ rejected = 0
+ details: list[dict[str, object]] = []
+ for index, item in enumerate(items):
+ text = clean_label(book_text[item.book_start : item.book_end])
+ duration = item.duration
+ if not (min_duration <= duration <= max_duration) or item.coverage < min_coverage:
+ rejected += 1
+ continue
+ if len(text) < 8 or len(text) > 320 or "|" in text:
+ rejected += 1
+ continue
+ clip_name = f"aletheia_ru_{len(rows):06d}.wav"
+ clip_path = wav_dir / clip_name
+ start = max(0.0, item.start - 0.06)
+ end = item.end + 0.08
+ if not is_valid_wav(clip_path, sample_rate):
+ subprocess.run(
+ [
+ ffmpeg,
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-y",
+ "-ss",
+ f"{start:.3f}",
+ "-i",
+ str(item.source),
+ "-t",
+ f"{end - start:.3f}",
+ "-ac",
+ "1",
+ "-ar",
+ str(sample_rate),
+ "-sample_fmt",
+ "s16",
+ str(clip_path),
+ ],
+ check=True,
+ )
+ rows.append((clip_name, text))
+ accepted_seconds += duration
+ details.append(
+ {
+ "clip": clip_name,
+ "source": str(item.source),
+ "start": round(item.start, 3),
+ "end": round(item.end, 3),
+ "duration": round(duration, 3),
+ "coverage": round(item.coverage, 4),
+ "text": text,
+ }
+ )
+ with metadata_path.open("w", encoding="utf-8", newline="") as output:
+ writer = csv.writer(output, delimiter="|", lineterminator="\n")
+ writer.writerows(rows)
+ report = {
+ "schema": 1,
+ "clips": len(rows),
+ "accepted_seconds": round(accepted_seconds, 3),
+ "accepted_hours": round(accepted_seconds / 3600, 4),
+ "rejected_candidates": rejected,
+ "sample_rate": sample_rate,
+ "min_duration": min_duration,
+ "max_duration": max_duration,
+ "min_coverage": min_coverage,
+ "items": details,
+ }
+ (output_dir / "dataset_report.json").write_text(
+ json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ return report
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--book-text", type=Path, required=True)
+ parser.add_argument("--transcripts-dir", type=Path, required=True)
+ parser.add_argument("--output-dir", type=Path, required=True)
+ parser.add_argument("--sample-rate", type=int, default=22050)
+ parser.add_argument("--min-duration", type=float, default=2.0)
+ parser.add_argument("--max-duration", type=float, default=12.0)
+ parser.add_argument("--min-coverage", type=float, default=0.62)
+ parser.add_argument("--dry-run", action="store_true")
+ args = parser.parse_args()
+
+ try:
+ import imageio_ffmpeg
+ except ImportError as error:
+ raise SystemExit("imageio-ffmpeg is missing from PYTHONPATH") from error
+
+ sidecars = sorted(args.transcripts_dir.glob("*.json"))
+ if not sidecars:
+ parser.error("No transcript sidecars found")
+ book_text, book_words = load_book(args.book_text)
+ asr_tokens, segments, _ = load_asr(sidecars)
+ mapping = token_mapping(asr_tokens, book_words)
+ aligned = align_segments(segments, mapping, book_words)
+ merged = merge_segments(aligned, book_text, args.max_duration)
+ summary = {
+ "transcript_files": len(sidecars),
+ "book_words": len(book_words),
+ "asr_tokens": len(asr_tokens),
+ "exact_token_matches": len(mapping),
+ "exact_token_match_ratio": round(len(mapping) / max(1, len(asr_tokens)), 4),
+ "aligned_segments": len(aligned),
+ "merged_candidates": len(merged),
+ }
+ if args.dry_run:
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
+ return 0
+ report = create_dataset(
+ merged,
+ book_text,
+ args.output_dir,
+ imageio_ffmpeg.get_ffmpeg_exe(),
+ args.sample_rate,
+ args.min_duration,
+ args.max_duration,
+ args.min_coverage,
+ )
+ report.update(summary)
+ (args.output_dir / "dataset_report.json").write_text(
+ json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ print(json.dumps({key: value for key, value in report.items() if key != "items"}, ensure_ascii=False, indent=2))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/tts/run_piper_training_windows.ps1 b/tools/tts/run_piper_training_windows.ps1
new file mode 100644
index 0000000..e05da9c
--- /dev/null
+++ b/tools/tts/run_piper_training_windows.ps1
@@ -0,0 +1,45 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+
+$root = $PSScriptRoot
+$runDir = Join-Path $root 'training'
+$stdoutPath = Join-Path $runDir 'scheduled-training.stdout.log'
+$stderrPath = Join-Path $runDir 'scheduled-training.stderr.log'
+$exitPath = Join-Path $runDir 'scheduled-training-exit.json'
+New-Item -ItemType Directory -Force -Path $runDir | Out-Null
+if (Test-Path -LiteralPath $exitPath) {
+ Remove-Item -LiteralPath $exitPath -Force
+}
+
+$exitCode = 1
+try {
+ $arguments = @(
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
+ '-File', (Join-Path $root 'train_piper_windows.ps1'),
+ '-DatasetDir', (Join-Path $root 'dataset'),
+ '-OutputDir', $runDir,
+ '-CacheDir', (Join-Path $root 'smoke\cache'),
+ '-PythonExe', (Join-Path $root '.venv\Scripts\python.exe'),
+ '-BatchSize', '16', '-NumWorkers', '0', '-MaxEpochs', '2000'
+ )
+ $process = Start-Process -FilePath 'powershell.exe' `
+ -ArgumentList $arguments `
+ -RedirectStandardOutput $stdoutPath `
+ -RedirectStandardError $stderrPath `
+ -WindowStyle Hidden `
+ -Wait `
+ -PassThru
+ $exitCode = $process.ExitCode
+} catch {
+ $_ | Out-String | Add-Content -LiteralPath $stderrPath -Encoding utf8
+ $exitCode = 1
+} finally {
+ [ordered]@{
+ exit_code = $exitCode
+ finished_utc = (Get-Date).ToUniversalTime().ToString('o')
+ } | ConvertTo-Json | Set-Content -LiteralPath $exitPath -Encoding utf8
+}
+
+exit $exitCode
diff --git a/tools/tts/train_piper.sh b/tools/tts/train_piper.sh
new file mode 100644
index 0000000..9824478
--- /dev/null
+++ b/tools/tts/train_piper.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -lt 3 ]]; then
+ echo "Usage: $0 DATASET_DIR OUTPUT_DIR PIPER_REPO [EPOCHS]" >&2
+ exit 2
+fi
+
+dataset_dir="$(realpath "$1")"
+output_dir="$(mkdir -p "$2" && realpath "$2")"
+piper_repo="$(realpath "$3")"
+epochs="${4:-2000}"
+
+metadata="$dataset_dir/metadata.csv"
+audio_dir="$dataset_dir/wav"
+if [[ ! -f "$metadata" || ! -d "$audio_dir" ]]; then
+ echo "Dataset must contain metadata.csv and wav/" >&2
+ exit 2
+fi
+if [[ ! -f "$piper_repo/src/piper/train/__main__.py" ]]; then
+ echo "Piper training sources not found at $piper_repo" >&2
+ exit 2
+fi
+
+python3 -m piper.train fit \
+ --data.voice_name aletheia_ru \
+ --data.csv_path "$metadata" \
+ --data.audio_dir "$audio_dir" \
+ --data.espeak_voice ru \
+ --data.cache_dir "$output_dir/cache" \
+ --data.config_path "$output_dir/aletheia_ru.onnx.json" \
+ --data.batch_size 16 \
+ --data.num_workers 4 \
+ --model.sample_rate 22050 \
+ --trainer.accelerator gpu \
+ --trainer.devices 1 \
+ --trainer.precision 16-mixed \
+ --trainer.max_epochs "$epochs" \
+ --trainer.default_root_dir "$output_dir/checkpoints"
+
+checkpoint="$(find "$output_dir/checkpoints" -type f -name '*.ckpt' -printf '%T@ %p\n' | sort -nr | head -n 1 | cut -d' ' -f2-)"
+if [[ -z "$checkpoint" ]]; then
+ echo "Training finished without a checkpoint" >&2
+ exit 1
+fi
+
+python3 -m piper.train.export_onnx \
+ --checkpoint "$checkpoint" \
+ --output-file "$output_dir/aletheia_ru.onnx"
+
+python3 - "$output_dir" <<'PY'
+import hashlib
+import json
+import pathlib
+import sys
+
+root = pathlib.Path(sys.argv[1])
+artifacts = {}
+for name in ("aletheia_ru.onnx", "aletheia_ru.onnx.json"):
+ path = root / name
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
+ artifacts[name] = {"bytes": path.stat().st_size, "sha256": digest}
+(root / "artifacts.json").write_text(json.dumps(artifacts, indent=2), encoding="utf-8")
+print(json.dumps(artifacts, indent=2))
+PY
diff --git a/tools/tts/train_piper_windows.ps1 b/tools/tts/train_piper_windows.ps1
new file mode 100644
index 0000000..4533900
--- /dev/null
+++ b/tools/tts/train_piper_windows.ps1
@@ -0,0 +1,102 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $DatasetDir,
+ [Parameter(Mandatory = $true)]
+ [string] $OutputDir,
+ [Parameter(Mandatory = $true)]
+ [string] $PythonExe,
+ [string] $CacheDir,
+ [int] $BatchSize = 4,
+ [int] $NumWorkers = 2,
+ [int] $MaxEpochs = 2000,
+ [switch] $SmokeTest
+)
+
+$ErrorActionPreference = 'Stop'
+
+$dataset = [IO.Path]::GetFullPath($DatasetDir)
+$output = [IO.Path]::GetFullPath($OutputDir)
+$python = [IO.Path]::GetFullPath($PythonExe)
+$metadata = Join-Path $dataset 'metadata.csv'
+$audioDir = Join-Path $dataset 'wav'
+$configPath = Join-Path $output 'aletheia_ru.onnx.json'
+$cacheDir = if ($CacheDir) {
+ [IO.Path]::GetFullPath($CacheDir)
+} else {
+ Join-Path $output 'cache'
+}
+$checkpointDir = Join-Path $output 'checkpoints'
+
+if (-not (Test-Path -LiteralPath $python -PathType Leaf)) {
+ throw "Python executable not found: $python"
+}
+if (-not (Test-Path -LiteralPath $metadata -PathType Leaf)) {
+ throw "Piper metadata not found: $metadata"
+}
+if (-not (Test-Path -LiteralPath $audioDir -PathType Container)) {
+ throw "Piper audio directory not found: $audioDir"
+}
+
+New-Item -ItemType Directory -Force -Path $output, $cacheDir, $checkpointDir | Out-Null
+
+$fitArgs = @(
+ '-m', 'piper.train', 'fit',
+ '--data.voice_name', 'aletheia_ru',
+ '--data.csv_path', $metadata,
+ '--data.audio_dir', $audioDir,
+ '--data.espeak_voice', 'ru',
+ '--data.cache_dir', $cacheDir,
+ '--data.config_path', $configPath,
+ '--data.batch_size', $BatchSize,
+ '--data.num_workers', $NumWorkers,
+ '--model.sample_rate', '22050',
+ '--trainer.accelerator', 'gpu',
+ '--trainer.devices', '1',
+ '--trainer.precision', '16-mixed',
+ '--trainer.max_epochs', $MaxEpochs,
+ '--trainer.default_root_dir', $checkpointDir
+)
+
+if ($SmokeTest) {
+ $fitArgs += @('--trainer.fast_dev_run', 'true', '--trainer.num_sanity_val_steps', '0')
+}
+
+& $python @fitArgs
+if ($LASTEXITCODE -ne 0) {
+ throw "Piper training exited with code $LASTEXITCODE"
+}
+
+if ($SmokeTest) {
+ Write-Output 'PIPER_CUDA_SMOKE_TEST_OK'
+ exit 0
+}
+
+$checkpoint = Get-ChildItem -LiteralPath $checkpointDir -Filter '*.ckpt' -File -Recurse |
+ Sort-Object LastWriteTimeUtc -Descending |
+ Select-Object -First 1
+if (-not $checkpoint) {
+ throw "Training finished without a checkpoint under $checkpointDir"
+}
+
+$onnxPath = Join-Path $output 'aletheia_ru.onnx'
+& $python -m piper.train.export_onnx --checkpoint $checkpoint.FullName --output-file $onnxPath
+if ($LASTEXITCODE -ne 0) {
+ throw "Piper ONNX export exited with code $LASTEXITCODE"
+}
+
+$artifacts = [ordered]@{}
+foreach ($artifactPath in @($onnxPath, $configPath)) {
+ if (-not (Test-Path -LiteralPath $artifactPath -PathType Leaf)) {
+ throw "Expected artifact not found: $artifactPath"
+ }
+ $item = Get-Item -LiteralPath $artifactPath
+ $artifacts[$item.Name] = [ordered]@{
+ bytes = $item.Length
+ sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+ }
+}
+
+$manifestPath = Join-Path $output 'artifacts.json'
+$artifacts | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding utf8
+$artifacts | ConvertTo-Json -Depth 4
diff --git a/tools/tts/transcribe_corpus.py b/tools/tts/transcribe_corpus.py
new file mode 100644
index 0000000..4927f9a
--- /dev/null
+++ b/tools/tts/transcribe_corpus.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""Create resumable word-timestamp ASR sidecars for audiobook tracks."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+import time
+from pathlib import Path
+
+
+def sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for block in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def serialize_segment(segment) -> dict[str, object]:
+ return {
+ "id": segment.id,
+ "start": round(segment.start, 3),
+ "end": round(segment.end, 3),
+ "text": segment.text.strip(),
+ "avg_logprob": round(segment.avg_logprob, 5),
+ "compression_ratio": round(segment.compression_ratio, 5),
+ "no_speech_prob": round(segment.no_speech_prob, 5),
+ "words": [
+ {
+ "start": round(word.start, 3),
+ "end": round(word.end, 3),
+ "word": word.word,
+ "probability": round(word.probability, 5),
+ }
+ for word in (segment.words or ())
+ ],
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--source-dir", type=Path, required=True)
+ parser.add_argument("--output-dir", type=Path, required=True)
+ parser.add_argument("--model", default="base")
+ parser.add_argument("--model-cache", type=Path, required=True)
+ parser.add_argument("--max-files", type=int)
+ parser.add_argument("--force", action="store_true")
+ args = parser.parse_args()
+
+ try:
+ from faster_whisper import WhisperModel
+ except ImportError as error:
+ raise SystemExit("faster-whisper is missing from PYTHONPATH") from error
+
+ files = sorted(args.source_dir.glob("*.mp3"))
+ if args.max_files is not None:
+ files = files[: max(0, args.max_files)]
+ if not files:
+ parser.error("No MP3 files found")
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+ model = WhisperModel(
+ args.model,
+ device="cpu",
+ compute_type="int8",
+ download_root=str(args.model_cache),
+ local_files_only=True,
+ )
+
+ completed = 0
+ for index, audio_path in enumerate(files, 1):
+ output_path = args.output_dir / f"{audio_path.stem[:4]}.json"
+ if output_path.is_file() and not args.force:
+ print(f"[{index}/{len(files)}] skip {audio_path.name}", flush=True)
+ completed += 1
+ continue
+ started = time.monotonic()
+ print(f"[{index}/{len(files)}] transcribe {audio_path.name}", flush=True)
+ segments_iter, info = model.transcribe(
+ str(audio_path),
+ language="ru",
+ beam_size=5,
+ vad_filter=True,
+ word_timestamps=True,
+ condition_on_previous_text=True,
+ )
+ segments = [serialize_segment(segment) for segment in segments_iter]
+ payload = {
+ "schema": 1,
+ "source": str(audio_path.resolve()),
+ "source_bytes": audio_path.stat().st_size,
+ "source_sha256": sha256(audio_path),
+ "language": info.language,
+ "language_probability": round(info.language_probability, 5),
+ "duration": round(info.duration, 3),
+ "duration_after_vad": round(info.duration_after_vad, 3),
+ "elapsed_seconds": round(time.monotonic() - started, 3),
+ "segments": segments,
+ }
+ temporary = output_path.with_suffix(".json.tmp")
+ temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ temporary.replace(output_path)
+ completed += 1
+ print(
+ f"[{index}/{len(files)}] wrote {output_path.name}: "
+ f"{len(segments)} segments in {payload['elapsed_seconds']}s",
+ flush=True,
+ )
+ print(json.dumps({"files": len(files), "completed": completed}, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())