Обновить библиотеку, читалку и выпуск до 2.31
This commit is contained in:
@@ -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,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"
|
||||
|
||||
@@ -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+0410–U+042F, and U+0430–U+044F. Droid Sans has no official italic file in the pinned AOSP directory, so its italic text is intentionally synthesized by WebView; every other selectable family has a real italic face.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,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
Reference in New Issue
Block a user