Обновить библиотеку, читалку и выпуск до 2.31

This commit is contained in:
Курнат Андрей
2026-07-17 14:38:49 +03:00
parent 2f40569031
commit c880395341
188 changed files with 15734 additions and 3702 deletions
+2 -2
View File
@@ -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 {
+15
View File
@@ -15,9 +15,24 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Aletheia">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".ui.reader.ReaderActivity"
android:exported="false"
android:theme="@style/Theme.Aletheia"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.qbooks.BookDetailActivity"
android:exported="false"
android:theme="@style/Theme.Aletheia" />
<activity
android:name=".ui.main.MainActivity"
+122
View File
@@ -0,0 +1,122 @@
# ReaderV2 bridge contract
`reader_v2/index.html` publishes one global object: `window.ReaderV2`.
All mutating/navigation methods return a `Promise`; `getStateJson()` returns a JSON string synchronously.
## Loading
```js
ReaderV2.loadBook({
id: "book-id", // optional native identifier
url: "https://appassets.androidplatform.net/book/current", // required
format: "epub", // "epub" or "fb2"; optional when URL/signature is sufficient
title: "Title", // optional fallback metadata
author: "Author", // optional fallback metadata
locator: { type: "epub", cfi: "epubcfi(...)" }, // optional
progress: 0.42, // optional fallback when locator is absent
cachedLocations: "[...]", // optional epub.js locations string
highlights: [ // optional initial restored annotations
{ locator: { type: "epub", cfi: "epubcfi(...)" }, color: "#ffd84a" }
],
preferences: { /* schema below */ }
});
```
The engine obtains the book only with `fetch(payload.url).arrayBuffer()`. A whole book is never transported as Base64. FB2 `<binary>` 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}`.
@@ -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;
}
}
@@ -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+0410U+042F, and U+0430U+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.
@@ -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/"
@@ -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.
@@ -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;
}
@@ -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
@@ -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
}
@@ -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.
@@ -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
}
@@ -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.
@@ -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"
@@ -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.
@@ -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"
}
@@ -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.
@@ -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"
}
@@ -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.
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Читалка</title>
<link id="reader-v2-font-faces" rel="stylesheet" href="fonts/fonts.css">
<link rel="stylesheet" href="css/reader.css">
</head>
<body class="theme-light">
<main id="reader-shell" aria-live="off">
<section id="epub-viewer" class="reader-surface" aria-label="Текст книги"></section>
<section id="fb2-viewer" class="reader-surface" aria-label="Текст книги" tabindex="0">
<article id="fb2-document"></article>
</section>
<section id="reader-status" role="status" aria-live="polite">
<span class="status-spinner" aria-hidden="true"></span>
<span id="reader-status-text">Подготовка читалки…</span>
</section>
</main>
<script src="/assets/wwwroot/js/jszip.min.js"></script>
<script src="/assets/wwwroot/js/epub.min.js"></script>
<script src="js/core.js"></script>
<script src="js/gestures.js"></script>
<script src="js/epub-engine.js"></script>
<script src="js/fb2-engine.js"></script>
<script src="js/reader.js"></script>
</body>
</html>
+367
View File
@@ -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);
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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);
+268
View File
@@ -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);
+29 -7
View File
@@ -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();
};
@@ -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)
}
}
@@ -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<Book> = 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<String>): Map<String, String> {
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<String, String>) {
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
}
}
@@ -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)
@@ -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)
}
}
@@ -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
}
@@ -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<QBooksBook> =
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<Acquisition>()
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<Element> = 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("<!DOCTYPE", ignoreCase = true)) {
"DOCTYPE запрещён в OPDS-ответе."
}
val factory = DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = true
runCatching { isXIncludeAware = false }
runCatching { setExpandEntityReferences(false) }
runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) }
runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) }
runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) }
runCatching { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) }
}
return factory.newDocumentBuilder().parse(InputSource(StringReader(xml)))
}
private data class Acquisition(
val href: String,
val format: String,
val priority: Int
)
private const val ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"
private const val DC_TERMS_NAMESPACE = "http://purl.org/dc/terms/"
private val COVER_RELS = setOf(
"http://opds-spec.org/image",
"http://opds-spec.org/image/thumbnail",
"x-stanza-cover-image",
"x-stanza-cover-image-thumbnail"
)
private val HTML_TAG = Regex("<[^>]+>")
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)
}
@@ -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<String, ByteArray> = Collections.synchronizedMap(
object : LinkedHashMap<String, ByteArray>(COVER_CACHE_ENTRIES + 1, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ByteArray>?): 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<Unit> = 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<List<QBooksBook>> = withContext(Dispatchers.IO) {
): Result<List<QBooksBook>> = 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<CatalogPage> = 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<File> = 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<QBooksBook> {
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>): 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+")
}
}
@@ -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 разрешён только в локальной сети."
}
}
@@ -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<String, String>) {
databaseHelper.setSettings(values)
}
fun getAll(keys: Collection<String>): Map<String, String> = 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"
}
}
@@ -0,0 +1,6 @@
package com.aletheia.app.model
data class CatalogPage(
val books: List<QBooksBook>,
val nextPageUrl: String? = null
)
@@ -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
@@ -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"
}
}
@@ -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<RecyclerView.ViewHolder>() {
private val items = mutableListOf<Book>()
@@ -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<Book>.shelfItemCount(): Int = if (isEmpty()) 0 else size + 1
private fun List<Book>.shelfItemCount(): Int =
if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
private companion object {
const val VIEW_TYPE_BOOK = 1
@@ -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<Book> = emptyList()
private var visibleBooks: List<Book> = emptyList()
private var continueReadingBook: Book? = null
private val metadataJobs = mutableListOf<Job>()
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<Book>) {
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<Book>, displayedBooks: List<Book>, 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<Book>, query: String): List<Book> {
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()
}
@@ -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,
@@ -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<QBooksBook> = emptyList()
private var catalogJob: Job? = null
private val coverJobs = mutableListOf<Job>()
private val localMetadataJobs = mutableListOf<Job>()
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<Book>) {
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<QBooksBook>) {
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<QBooksBook>) {
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()
}
}
@@ -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<ConstraintLayout.LayoutParams> {
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"
}
}
@@ -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<FrameLayout.LayoutParams> {
topMargin = backTopMargin + bars.top
}
binding.shareButton.updateLayoutParams<FrameLayout.LayoutParams> {
topMargin = shareTopMargin + bars.top
}
binding.favoriteButton.updateLayoutParams<FrameLayout.LayoutParams> {
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))
}
}
@@ -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<QBooksCatalogAdapter.QBooksBookViewHolder>() {
@@ -44,8 +45,31 @@ class QBooksCatalogAdapter(
notifyItemRangeInserted(startIndex, newItems.size)
}
fun appendOrUpdate(books: List<QBooksBook>) {
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<QBooksBook> = 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) }
}
}
@@ -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<Job>()
private val activeDownloads = mutableSetOf<String>()
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<QBooksBook>, 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()
}
}
File diff suppressed because it is too large Load Diff
@@ -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<ReaderTocEntry>) : 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<ReaderTocEntry> = 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?
)
@@ -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()
}
}
@@ -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$")
}
}
@@ -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
)
}
}
@@ -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<Long, ReaderSessionPosition>()
private val persistedSignatures = ConcurrentHashMap<Long, String>()
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
)
@@ -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<ReadingNote>) {
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)
)
}
}
}
@@ -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<android.speech.tts.Voice> { 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
}
}
@@ -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
}
@@ -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<ThemeOption>
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"
@@ -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\\\\/:*?\"<>|]")
}
@@ -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 не новее установленного приложения."
)
}
@@ -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(
@@ -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"
}
}
@@ -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 {
@@ -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()
}
}
}
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#332A1A0C" />
<item android:color="#00FFFFFF" />
</selector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/night_gold" android:state_checked="true" />
<item android:color="@color/night_muted" />
<item android:color="@color/home_accent" android:state_checked="true" />
<item android:color="@color/home_secondary" />
</selector>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/white" />
<corners android:topLeftRadius="28dp" android:topRightRadius="28dp" />
</shape>
@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E609120D" />
<stroke
android:width="1dp"
android:color="@color/night_border" />
<corners android:radius="26dp" />
<solid android:color="@color/white" />
<stroke android:width="0dp" android:color="@android:color/transparent" />
<corners android:radius="0dp" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#EEFFFFFF" />
<stroke android:width="1dp" android:color="#22A0A0A8" />
</shape>
+1 -5
View File
@@ -1,8 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="315"
android:centerColor="@color/night_background"
android:endColor="@color/night_background_deep"
android:startColor="@color/night_surface" />
<solid android:color="@color/app_background" />
</shape>
@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="0" android:startColor="#E83F4E" android:endColor="#A7276A" />
<corners android:radius="24dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="0" android:startColor="#102A56" android:centerColor="#174F79" android:endColor="#B04C3D" />
<corners android:radius="22dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="0" android:startColor="#E614192C" android:centerColor="#9914192C" android:endColor="#0014192C" />
<corners android:radius="22dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="0" android:startColor="#171B3F" android:centerColor="#4B2E61" android:endColor="#C05A35" />
<corners android:radius="22dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:angle="0" android:startColor="#143C43" android:centerColor="#1F6265" android:endColor="#C58A42" />
<corners android:radius="22dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/home_surface" />
<corners android:radius="17dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/home_surface" />
<corners android:radius="10dp" />
</shape>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/home_border" />
<corners android:radius="23dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/home_surface_strong" />
<corners android:radius="23dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/home_surface" />
<corners android:radius="22dp" />
</shape>

Some files were not shown because too many files have changed in this diff Show More